Repository: Neoteroi/BlackSheep
Pull request #324 (2023-04-01 21:11:37)

Add support for route filters

codecov-commenter: ## [Codecov](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`96.51`**% and project coverage change: **`-0.11`** :warning:
> Comparison is base [(`c2b3451`)](https://codecov.io/gh/Neoteroi/BlackSheep/commit/c2b3451412ecd516aee04c4b39164424e22e90f5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.37% compared to head [(`7d77abb`)](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.26%.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

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


```diff
@@            Coverage Diff             @@
##             main     #324      +/-   ##
==========================================
- Coverage   97.37%   97.26%   -0.11%     
==========================================
  Files          62       64       +2     
  Lines        5910     6043     +133     
==========================================
+ Hits         5755     5878     +123     
- Misses        155      165      +10     
```


| [Impacted Files](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/server/application.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvYXBwbGljYXRpb24ucHk=) | `92.18% <68.75%> (-0.90%)` | :arrow_down: |
| [blacksheep/common/types.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jb21tb24vdHlwZXMucHk=) | `90.90% <90.90%> (ø)` | |
| [blacksheep/client/session.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jbGllbnQvc2Vzc2lvbi5weQ==) | `99.57% <100.00%> (-0.02%)` | :arrow_down: |
| [blacksheep/common/\_\_init\_\_.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jb21tb24vX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [blacksheep/server/controllers.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvY29udHJvbGxlcnMucHk=) | `100.00% <100.00%> (ø)` | |
| [blacksheep/server/routing.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvcm91dGluZy5weQ==) | `100.00% <100.00%> (ø)` | |

... and [7 files with indirect coverage changes](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/Neoteroi/BlackSheep/pull/324?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](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 22286ce..516f12c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ 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.0a5] - 2023-04-02 :fish:
+
+- Adds support for user defined filters for server routes (`RouteFilter` class).
+- Adds built-in support for routing based on request headers.
+- Adds built-in support for routing based on request query parameters.
+- Adds built-in support for routing based on host header value.
+- Adds a `query.setter` to the `Request` class, to set queries using
+  `dict[str, str | sequence[str]]` as input.
+- The functions registered to application events don't need anymore to define
+  the `app` argument (they can be functions without any argument).
+- Adds `Cache-Control: no-cache, no-store' to all responses generated for the
+  OpenID Connect flow.
+
 ## [2.0a4] - 2023-03-19 :flamingo:
 
 - Adds `@app.lifespan` to support registering objects that must be initialized
diff --git a/blacksheep/baseapp.pyi b/blacksheep/baseapp.pyi
index fc61cf4..67eed51 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, Router
+from blacksheep.server.routing import RouteMatch, RouterProtocol
 
 ExcT = TypeVar("ExcT", bound=Exception)
 
@@ -14,7 +14,7 @@ ExceptionHandlersType = Dict[
 ]
 
 class BaseApplication:
-    def __init__(self, show_error_details: bool, router: Router):
+    def __init__(self, show_error_details: bool, router: RouterProtocol):
         self.router = router
         self.exceptions_handlers = self.init_exceptions_handlers()
         self.show_error_details = show_error_details
diff --git a/blacksheep/baseapp.pyx b/blacksheep/baseapp.pyx
index 9ce28cb..96bf1e5 100644
--- a/blacksheep/baseapp.pyx
+++ b/blacksheep/baseapp.pyx
@@ -68,14 +68,11 @@ cdef class BaseApplication:
                 str(exc)
             )
 
-    def get_route_match(self, Request request):
-        return self.router.get_match(request.method, request._path)
-
     async def handle(self, Request request):
         cdef object route
         cdef Response response
 
-        route = self.get_route_match(request)
+        route = self.router.get_match(request)
 
         if route:
             request.route_values = route.values
diff --git a/blacksheep/client/session.py b/blacksheep/client/session.py
index ee73e0e..621b3ca 100644
--- a/blacksheep/client/session.py
+++ b/blacksheep/client/session.py
@@ -1,22 +1,11 @@
 import asyncio
 import ssl
 from asyncio import AbstractEventLoop, TimeoutError
-from typing import (
-    Any,
-    AnyStr,
-    Callable,
-    Dict,
-    Iterable,
-    List,
-    Optional,
-    Tuple,
-    Type,
-    Union,
-    cast,
-)
+from typing import Any, AnyStr, Callable, Dict, List, Optional, Tuple, Type, Union, cast
 from urllib.parse import urlencode
 
 from blacksheep import URL, Content, InvalidURL, Request, Response
+from blacksheep.common.types import HeadersType, ParamsType, URLType, normalize_headers
 from blacksheep.middlewares import get_middlewares_chain
 from blacksheep.utils.aio import get_running_loop
 
@@ -32,31 +21,6 @@ from .exceptions import (
 )
 from .pool import ClientConnection, ConnectionPools
 
-URLType = Union[str, bytes, URL]
-KeyValuePair = Tuple[AnyStr, AnyStr]
-Headers = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]]
-Params = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]]
-
-
-def _ensure_header_bytes(value: AnyStr) -> bytes:
-    return value if isinstance(value, bytes) else value.encode("ascii")
-
-
-def normalize_headers(
-    headers: Optional[Headers],
-) -> Optional[List[Tuple[bytes, bytes]]]:
-    if headers is None:
-        return None
-    if isinstance(headers, dict):
-        return [
-            (_ensure_header_bytes(key), _ensure_header_bytes(value))
-            for key, value in headers.items()
-        ]
-    return [
-        (_ensure_header_bytes(key), _ensure_header_bytes(value))
-        for key, value in headers
-    ]
-
 
 class RedirectsCache:
     """Used to store permanent redirects urls for later reuse"""
@@ -94,7 +58,7 @@ class ClientSession:
         base_url: Union[None, bytes, str, URL] = None,
         ssl: Union[None, bool, ssl.SSLContext] = None,
         pools: Optional[ConnectionPools] = None,
-        default_headers: Optional[Headers] = None,
+        default_headers: Optional[HeadersType] = None,
         follow_redirects: bool = True,
         connection_timeout: float = 10.0,
         request_timeout: float = 60.0,
@@ -152,7 +116,7 @@ class ClientSession:
         return self._default_headers
 
     @default_headers.setter
-    def default_headers(self, value: Optional[Headers]):
+    def default_headers(self, value: Optional[HeadersType]):
         self._default_headers = normalize_headers(value)
 
     @property
@@ -195,7 +159,7 @@ class ClientSession:
         """Uses specification compliant handling of 301 and 302 redirects"""
         self.non_standard_handling_of_301_302_redirect_method = False
 
-    def get_url(self, url, params: Optional[Params] = None) -> bytes:
+    def get_url(self, url, params: Optional[ParamsType] = None) -> bytes:
         value = self.get_url_value(url)
         if not params:
             return value
@@ -386,8 +350,8 @@ class ClientSession:
     async def get(
         self,
         url: URLType,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         return await self.send(
             Request("GET", self.get_url(url, params), normalize_headers(headers))
@@ -397,8 +361,8 @@ class ClientSession:
         self,
         url: URLType,
         content: Optional[Content] = None,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         request = Request("POST", self.get_url(url, params), normalize_headers(headers))
         return await self.send(
@@ -409,8 +373,8 @@ class ClientSession:
         self,
         url: URLType,
         content: Optional[Content] = None,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         request = Request("PUT", self.get_url(url, params), normalize_headers(headers))
         return await self.send(
@@ -421,8 +385,8 @@ class ClientSession:
         self,
         url: URLType,
         content: Optional[Content] = None,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         request = Request(
             "DELETE", self.get_url(url, params), normalize_headers(headers)
@@ -434,8 +398,8 @@ class ClientSession:
     async def trace(
         self,
         url: URLType,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         return await self.send(
             Request("TRACE", self.get_url(url, params), normalize_headers(headers))
@@ -444,8 +408,8 @@ class ClientSession:
     async def head(
         self,
         url: URLType,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         return await self.send(
             Request("HEAD", self.get_url(url, params), normalize_headers(headers))
@@ -455,8 +419,8 @@ class ClientSession:
         self,
         url: URLType,
         content: Optional[Content] = None,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         request = Request(
             "PATCH", self.get_url(url, params), normalize_headers(headers)
@@ -469,8 +433,8 @@ class ClientSession:
         self,
         url: URLType,
         content: Optional[Content] = None,
-        headers: Optional[Headers] = None,
-        params: Optional[Params] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
     ) -> Response:
         request = Request(
             "OPTIONS", self.get_url(url, params), normalize_headers(headers)
diff --git a/blacksheep/common/__init__.py b/blacksheep/common/__init__.py
index e69de29..0396872 100644
--- a/blacksheep/common/__init__.py
+++ b/blacksheep/common/__init__.py
@@ -0,0 +1,10 @@
+def extend(obj, cls):
+    """
+    Applies a mixin to an instance of a class.
+
+    This method is used in those scenarios where opting-in for a feature incurs a
+    performance fee, so that said fee is paid only when features are used.
+    """
+    base_cls = obj.__class__
+    base_cls_name = obj.__class__.__name__ + "_" + cls.__name__
+    obj.__class__ = type(base_cls_name, (cls, base_cls), {})
diff --git a/blacksheep/common/types.py b/blacksheep/common/types.py
new file mode 100644
index 0000000..a69d321
--- /dev/null
+++ b/blacksheep/common/types.py
@@ -0,0 +1,46 @@
+"""
+Common types annotations and functions.
+"""
+from typing import AnyStr, Dict, Iterable, List, Optional, Tuple, Union
+
+from blacksheep.url import URL
+
+KeyValuePair = Tuple[AnyStr, AnyStr]
+HeadersType = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]]
+ParamsType = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]]
+URLType = Union[str, bytes, URL]
+
+
+def _ensure_header_bytes(value: AnyStr) -> bytes:
+    return value if isinstance(value, bytes) else value.encode("ascii")
+
+
+def _ensure_param_str(value: AnyStr) -> str:
+    return value if isinstance(value, str) else value.decode("ascii")
+
+
+def normalize_headers(
+    headers: Optional[HeadersType],
+) -> Optional[List[Tuple[bytes, bytes]]]:
+    if headers is None:
+        return None
+    if isinstance(headers, dict):
+        return [
+            (_ensure_header_bytes(key), _ensure_header_bytes(value))  # type: ignore
+            for key, value in headers.items()
+        ]
+    return [
+        (_ensure_header_bytes(key), _ensure_header_bytes(value))
+        for key, value in headers
+    ]
+
+
+def normalize_params(params: Optional[ParamsType]) -> Optional[List[Tuple[str, str]]]:
+    if params is None:
+        return None
+    if isinstance(params, dict):
+        return [
+            (_ensure_param_str(key), _ensure_param_str(value))  # type: ignore
+            for key, value in params.items()
+        ]
+    return [(_ensure_param_str(key), _ensure_param_str(value)) for key, value in params]
diff --git a/blacksheep/messages.pyi b/blacksheep/messages.pyi
index e6844c5..00e42aa 100644
--- a/blacksheep/messages.pyi
+++ b/blacksheep/messages.pyi
@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Generator, List, Optional, Union
+from typing import Any, Callable, Dict, Generator, List, Optional, Sequence, Union
 
 from guardpost import Identity
 
@@ -74,6 +74,8 @@ class Request(Message):
     ) -> "Request": ...
     @property
     def query(self) -> Dict[str, List[str]]: ...
+    @query.setter
+    def query(self, value: Dict[str, Union[str, Sequence[str]]]): ...
     @property
     def url(self) -> URL: ...
     @url.setter
diff --git a/blacksheep/messages.pyx b/blacksheep/messages.pyx
index 91c6283..5ae92b9 100644
--- a/blacksheep/messages.pyx
+++ b/blacksheep/messages.pyx
@@ -2,7 +2,7 @@ import http
 import re
 from datetime import datetime, timedelta
 from json.decoder import JSONDecodeError
-from urllib.parse import parse_qs, quote, unquote
+from urllib.parse import parse_qs, quote, unquote, urlencode
 
 try:
     import cchardet as chardet
@@ -375,9 +375,16 @@ cdef class Request(Message):
     @property
     def query(self):
         if self._raw_query:
-            return parse_qs(self._raw_query.decode('utf8'))
+            return parse_qs(self._raw_query.decode("utf8"))
         return {}
 
+    @query.setter
+    def query(self, value):
+        cdef bytes raw_query
+        raw_query = urlencode(value, True).encode("utf8")
+        self._raw_query = raw_query
+        self.url = self.url.with_query(raw_query)
+
     @property
     def url(self):
         if self._url:
diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py
index 753d7d5..e004d4e 100644
--- a/blacksheep/server/application.py
+++ b/blacksheep/server/application.py
@@ -1,5 +1,7 @@
 import logging
 from contextlib import asynccontextmanager
+from functools import wraps
+from inspect import signature, unwrap
 from pathlib import Path
 from typing import (
     Any,
@@ -26,6 +28,7 @@ from itsdangerous import Serializer
 from rodi import ContainerProtocol
 
 from blacksheep.baseapp import BaseApplication, handle_not_found
+from blacksheep.common import extend
 from blacksheep.common.files.asyncfs import FilesHandler
 from blacksheep.contents import ASGIContent
 from blacksheep.exceptions import HTTPException
@@ -86,11 +89,17 @@ class ApplicationEvent:
         self.context = context
 
     def __iadd__(self, handler: Callable[..., Any]) -> "ApplicationEvent":
-        self._handlers.append(handler)
+        self._handlers.append(self._wrap_discard(handler))
         return self
 
     def __isub__(self, handler: Callable[..., Any]) -> "ApplicationEvent":
-        self._handlers.remove(handler)
+        to_remove = [
+            callback
+            for callback in self._handlers
+            if callback is handler or unwrap(callback) is handler
+        ]
+        for callback in to_remove:
+            self._handlers.remove(callback)
         return self
 
     def __len__(self) -> int:
@@ -111,6 +120,21 @@ class ApplicationEvent:
         for handler in self._handlers:
             await handler(self.context, *args, **kwargs)
 
+    def _wrap_discard(self, function):
+        """
+        If the given function does not accept any parameter, returns a wrapper with a
+        discard parameter; otherwise returns the same function.
+        """
+        if len(signature(function).parameters) == 0:
+
+            @wraps(function)
+            async def wrap_handler(_):
+                await function()
+
+            return wrap_handler
+        else:
+            return function
+
 
 class ApplicationSyncEvent(ApplicationEvent):
     """
@@ -139,13 +163,6 @@ class ApplicationAlreadyStartedCORSError(TypeError):
         )
 
 
-def _extend(obj, cls):
-    """Applies a mixin to an instance of a class."""
-    base_cls = obj.__class__
-    base_cls_name = obj.__class__.__name__
-    obj.__class__ = type(base_cls_name, (cls, base_cls), {})
-
-
 class Application(BaseApplication):
     """
     Server application class.
@@ -584,6 +601,7 @@ class Application(BaseApplication):
                 route.method,
                 self.get_controller_handler_pattern(controller_type, route),
                 handler,
+                controller_type._filters_,
             )
         return controller_types
 
@@ -667,8 +685,11 @@ class Application(BaseApplication):
     def extend(self, mixin) -> None:
         """
         Extends the class with additional features, applying the given mixin class.
+
+        This method should be used for those scenarios where opting-in for a feature
+        incurs a performance fee, so that said fee is paid only when necessary.
         """
-        _extend(self, mixin)
+        extend(self, mixin)
 
     async def start(self):
         if self.started:
@@ -792,7 +813,7 @@ class MountMixin:
             return await super()._handle_lifespan(receive, send)  # type: ignore
 
         for route in self.mount_registry.mounted_apps:  # type: ignore
-            route_match = route.match(scope["raw_path"])
+            route_match = route.match_by_path(scope["raw_path"])
             if route_match:
                 raw_path = scope["raw_path"]
                 if raw_path == route.pattern.rstrip(b"/*") and scope["type"] == "http":
diff --git a/blacksheep/server/authentication/oidc.py b/blacksheep/server/authentication/oidc.py
index 2f0d291..80b19bf 100644
--- a/blacksheep/server/authentication/oidc.py
+++ b/blacksheep/server/authentication/oidc.py
@@ -25,6 +25,7 @@ from blacksheep.server.authentication.cookie import CookieAuthentication
 from blacksheep.server.authentication.jwt import JWTBearerAuthentication
 from blacksheep.server.authorization import allow_anonymous
 from blacksheep.server.dataprotection import generate_secret, get_serializer
+from blacksheep.server.headers.cache import cache_control
 from blacksheep.server.responses import accepted, bad_request, html, json, ok, redirect
 from blacksheep.utils import ensure_str
 from blacksheep.utils.aio import FailedRequestError, HTTPHandler
@@ -1071,19 +1072,23 @@ def use_openid_connect(
 
     @allow_anonymous()
     @app.router.get(settings.entry_path)
+    @cache_control(no_cache=True, no_store=True)
     async def redirect_to_sign_in(request: Request):
         return await handler.redirect_to_sign_in(request)
 
     @allow_anonymous()
     @app.router.post(settings.callback_path)
+    @cache_control(no_cache=True, no_store=True)
     async def handle_auth_redirect(request: Request):
         return await handler.handle_auth_redirect(request)
 
     @app.router.get(settings.logout_path)
+    @cache_control(no_cache=True, no_store=True)
     async def redirect_to_logout(request: Request):
         return await handler.handle_logout_redirect(request)
 
     @app.router.post(settings.refresh_token_path)
+    @cache_control(no_cache=True, no_store=True)
     async def refresh_token(request: Request):
         return await handler.handle_refresh_token_request(request)
 
diff --git a/blacksheep/server/controllers.py b/blacksheep/server/controllers.py
index c9576d4..6cf8482 100644
--- a/blacksheep/server/controllers.py
+++ b/blacksheep/server/controllers.py
@@ -1,8 +1,18 @@
 import sys
 from io import BytesIO
-from typing import Any, AsyncIterable, Callable, Optional, Union
+from typing import (
+    Any,
+    AsyncIterable,
+    Callable,
+    ClassVar,
+    Optional,
+    Sequence,
+    Type,
+    Union,
+)
 
 from blacksheep import Request, Response
+from blacksheep.common.types import HeadersType, ParamsType
 from blacksheep.server.responses import (
     ContentDispositionType,
     MessageType,
@@ -28,7 +38,7 @@ from blacksheep.server.responses import (
     view,
     view_async,
 )
-from blacksheep.server.routing import RoutesRegistry
+from blacksheep.server.routing import RouteFilter, RoutesRegistry, normalize_filters
 from blacksheep.utils import AnyStr, join_fragments
 
 # singleton router used to store initial configuration,
@@ -50,6 +60,35 @@ connect = router.connect
 ws = router.ws
 
 
+def filters(
+    *filters: RouteFilter,
+    host: Optional[str] = None,
+    headers: Optional[HeadersType] = None,
+    params: Optional[ParamsType] = None,
+):
+    """
+    Configures a set of filters for a decorated controller type.
+    Filters are applied to all routes defined in the controller.
+
+    ---
+    Example: match a "/" route only if the request includes an header "X-Area: Special"
+
+    ```py
+    @filters(headers={"X-Area": "Special"})
+    class Special(Controller):
+        @get("/")
+        def special(self):
+            ...
+    ```
+    """
+
+    def class_deco(cls: Type["Controller"]):
+        cls._filters_ = normalize_filters(host, headers, params, list(*filters))
+        return cls
+
+    return class_deco
+
+
 class CannotDetermineDefaultViewNameError(RuntimeError):
     def __init__(self):
         super().__init__(
@@ -72,6 +111,8 @@ class ControllerMeta(type):
 class Controller(metaclass=ControllerMeta):
     """Base class for all controllers."""
 
+    _filters_: ClassVar[Sequence[RouteFilter]] = []
+
     @classmethod
     def route(cls) -> Optional[str]:
         """
@@ -244,7 +285,7 @@ class Controller(metaclass=ControllerMeta):
         ],
         content_type: str,
         *,
-        file_name: str = None,
+        file_name: Optional[str] = None,
         content_disposition: ContentDispositionType = ContentDispositionType.ATTACHMENT,
     ) -> Response:
         """
@@ -383,7 +424,3 @@ 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)
-
-
-# For backward compatibility
-ApiController = APIController
diff --git a/blacksheep/server/headers/cache.py b/blacksheep/server/headers/cache.py
index f5cfcc4..41d15a9 100644
--- a/blacksheep/server/headers/cache.py
+++ b/blacksheep/server/headers/cache.py
@@ -8,7 +8,6 @@ from functools import wraps
 from typing import Optional
 
 from blacksheep import Request, Response
-from blacksheep.normalization import copy_special_attributes
 from blacksheep.server.normalization import ensure_response
 
 
@@ -73,6 +72,12 @@ def write_cache_control_response_header(
             value.extend(b", ")
         value.extend(part)
 
+    if private:
+        extend(b"private")
+
+    if public:
+        extend(b"public")
+
     if max_age is not None:
         extend(f"max-age={max_age}".encode("ascii"))
 
@@ -91,12 +96,6 @@ def write_cache_control_response_header(
     if no_store:
         extend(b"no-store")
 
-    if private:
-        extend(b"private")
-
-    if public:
-        extend(b"public")
-
     if must_understand:
         extend(b"must-understand")
 
@@ -271,7 +270,6 @@ def cache_control(
                 response.add_header(b"cache-control", header_value)
                 return response
 
-            copy_special_attributes(next_handler, wrapped)
             return wrapped
 
     return decorator
diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py
index b3cd8e8..ad43e79 100644
--- a/blacksheep/server/routing.py
+++ b/blacksheep/server/routing.py
@@ -1,11 +1,19 @@
 import logging
 import re
-from abc import abstractmethod
+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 urllib.parse import unquote
 
+from blacksheep.common import extend
+from blacksheep.common.types import (
+    HeadersType,
+    ParamsType,
+    normalize_headers,
+    normalize_params,
+)
+from blacksheep.messages import Request
 from blacksheep.utils import ensure_bytes, ensure_str
 
 __all__ = [
@@ -78,17 +86,109 @@ class InvalidValuePatternName(RouteException):
 
 
 class RouteMatch:
-    __slots__ = ("values", "pattern", "handler")
+    __slots__ = ("_values", "pattern", "handler")
 
     def __init__(self, route: "Route", values: Optional[Dict[str, bytes]]):
         self.handler = route.handler
         self.pattern = route.pattern
-        self.values: Optional[Dict[str, str]] = (
+        self._values: Optional[Dict[str, str]] = (
             {k: unquote(v.decode("utf8")) for k, v in values.items()}
             if values
             else None
         )
 
+    @property
+    def values(self) -> Optional[Dict[str, str]]:
+        return self._values
+
+
+class RouteFilter(ABC):
+    @abstractmethod
+    def handle(self, request: Request) -> bool:
+        """
+        Returns a value indicating whether a request should be handled by a request
+        handler.
+        For example, to filter requests by headers.
+        """
+
+
+class HeadersFilter(RouteFilter):
+    """
+    Filters requests by required headers values.
+    """
+
+    def __init__(self, required_headers: HeadersType) -> None:
+        self.required_headers = normalize_headers(required_headers) or []
+
+    def handle(self, request: Request) -> bool:
+        for key, value in self.required_headers:
+            if request.get_first_header(key) != value:
+                return False
+        return True
+
+
+class ParamsFilter(RouteFilter):
+    """
+    Filters requests by required query parameters.
+    """
+
+    def __init__(self, required_params: ParamsType) -> None:
+        self.required_params = normalize_params(required_params) or []
+
+    def handle(self, request: Request) -> bool:
+        for key, value in self.required_params:
+            query = request.query.get(key)
+            if query is not None and len(query) == 1 and query[0] == value:
+                continue
+            if query != value:
+                return False
+        return True
+
+
+class HostFilter(RouteFilter):
+    """
+    Filters requests by host value. The comparison is always case insensitive.
+    By default the port number is ignored, if present in request headers, unless the
+    given host value itself includes the character ":".
+    """
+
+    def __init__(self, value: str, ignore_port: bool = True) -> None:
+        self._host = value.lower()
+        self._ignore_port = ignore_port if ":" not in value else False
+
+    @property
+    def host(self) -> str:
+        return self._host
+
+    def handle(self, request: Request) -> bool:
+        req_host = request.host.lower()
+        if self._ignore_port and ":" in req_host:
+            req_host = req_host[0 : req_host.index(":")]
+        return req_host == self._host
+
+
+def normalize_filters(
+    host: Optional[str] = None,
+    headers: Optional[HeadersType] = None,
+    params: Optional[ParamsType] = None,
+    filters: Optional[List[RouteFilter]] = None,
+) -> List[RouteFilter]:
+    if filters:
+        filters = filters.copy()
+    else:
+        filters = []
+
+    if headers:
+        filters.insert(0, HeadersFilter(headers))
+
+    if params:
+        filters.insert(0, ParamsFilter(params))
+
+    if host:
+        filters.insert(0, HostFilter(host))
+
+    return filters
+
 
 def _get_parameter_pattern_fragment(
     parameter_name: bytes, value_pattern: bytes = rb"[^\/]+"
@@ -116,7 +216,11 @@ class Route:
         + r"{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}",
     }
 
-    def __init__(self, pattern: Union[str, bytes], handler: Any):
+    def __init__(
+        self,
+        pattern: Union[str, bytes],
+        handler: Any,
+    ):
         raw_pattern = self.normalize_pattern(pattern)
         self.handler = handler
         self.pattern = raw_pattern
@@ -243,7 +347,7 @@ class Route:
         return raw_pattern
 
     def __repr__(self) -> str:
-        return f"<Route {self.pattern.decode('utf8')}>"
+        return f"<{self.__class__.__name__} \"{self.pattern.decode('utf8')}\">"
 
     @staticmethod
     def _normalize_rich_parameter(match: re.Match):
@@ -269,11 +373,18 @@ class Route:
     def full_pattern(self) -> bytes:
         return self._rx.pattern
 
-    def match(self, value: bytes) -> Optional[RouteMatch]:
-        if not self.has_params and value.lower() == self.pattern:
+    def match(self, request: Request) -> Optional[RouteMatch]:
+        return self.match_by_path(ensure_bytes(request._path))
+
+    def match_by_path(self, path: bytes) -> Optional[RouteMatch]:
+        """
+        Returns a match by path - this method can be used only when the route does not
+        define any filter.
+        """
+        if not self.has_params and path.lower() == self.pattern:
             return RouteMatch(self, None)
 
-        match = self._rx.match(value)
+        match = self._rx.match(path)
 
         if not match:
             return None
@@ -281,9 +392,62 @@ class Route:
         return RouteMatch(self, match.groupdict() if self.has_params else None)
 
 
-class RouterBase:
+class FilterRoute(Route):
+    """
+    Route class that supports filters for requests.
+    """
+
+    __slots__ = (
+        "handler",
+        "pattern",
+        "filters",
+        "param_names",
+        "_rx",
+    )
+
+    def __init__(
+        self,
+        pattern: Union[str, bytes],
+        handler: Any,
+        filters: List[RouteFilter],
+    ):
+        super().__init__(pattern, handler)
+        self.filters = filters
+
+    def match(self, request: Request) -> Optional[RouteMatch]:
+        match = super().match(request)
+
+        if not match:
+            return None
+
+        if all(action_filter.handle(request) for action_filter in self.filters):
+            return match
+
+        return None
+
+
+class RouterBase(ABC):
+    """
+    Base abstract class for types that can register types and can implement filters.
+    """
+
+    def __init__(
+        self,
+        *,
+        host: Optional[str] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
+        filters: Optional[List[RouteFilter]] = None,
+    ):
+        self._filters = normalize_filters(host, headers, params, filters)
+
     @abstractmethod
-    def add(self, method: str, pattern: str, handler: Callable) -> None:
+    def add(
+        self,
+        method: str,
+        pattern: str,
+        handler: Callable,
+    ) -> None:
         """Adds a request handler for the given HTTP method and route pattern."""
 
     def mark_handler(self, handler: Callable) -> None:
@@ -292,8 +456,10 @@ class RouterBase:
     def normalize_default_pattern_name(self, handler_name: str) -> str:
         return handler_name.replace("_", "-")
 
-    def get_decorator(
-        self, method: str, pattern: Optional[str] = "/"
+    def _get_decorator(
+        self,
+        method: str,
+        pattern: Optional[str] = "/",
     ) -> Callable[..., Any]:
         def decorator(fn):
             nonlocal pattern
@@ -345,43 +511,114 @@ class RouterBase:
         self.add(RouteMethod.GET_WS, pattern, handler)
 
     def head(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.HEAD, pattern)
+        return self._get_decorator(RouteMethod.HEAD, pattern)
 
     def get(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.GET, pattern)
+        return self._get_decorator(RouteMethod.GET, pattern)
 
     def post(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.POST, pattern)
+        return self._get_decorator(RouteMethod.POST, pattern)
 
     def put(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.PUT, pattern)
+        return self._get_decorator(RouteMethod.PUT, pattern)
 
     def delete(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.DELETE, pattern)
+        return self._get_decorator(RouteMethod.DELETE, pattern)
 
     def trace(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.TRACE, pattern)
+        return self._get_decorator(RouteMethod.TRACE, pattern)
 
     def options(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.OPTIONS, pattern)
+        return self._get_decorator(RouteMethod.OPTIONS, pattern)
 
     def connect(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.CONNECT, pattern)
+        return self._get_decorator(RouteMethod.CONNECT, pattern)
 
     def patch(self, pattern: Optional[str] = "/") -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.PATCH, pattern)
+        return self._get_decorator(RouteMethod.PATCH, pattern)
 
     def ws(self, pattern) -> Callable[..., Any]:
-        return self.get_decorator(RouteMethod.GET_WS, pattern)
+        return self._get_decorator(RouteMethod.GET_WS, pattern)
+
+
+class MultiRouterMixin:
+    """
+    This mixin is activate automatically when a Router defines sub-routers.
+    """
+
+    _sub_routers: List["Router"]
+
+    def __iter__(self):
+        yield from super().__iter__()  # type: ignore
+
+        for router in self._sub_routers:
+            yield from router
+
+    def get_match(self, request: Request) -> Optional[RouteMatch]:
+        for router in self._sub_routers:
+            match = router.get_match(request)
+
+            if match:
+                return match
+
+        return super().get_match(request)  # type: ignore
+
+
+class RouterFiltersMixin:
+    """
+    This mixin is activated automatically when any of the routes defined for a web app
+    uses filters (RouteFilter). The rationale for using a mixin is that using filters
+    incurs a performance fee, and fees should only be paid when using features.
+    """
+
+    routes: Dict[bytes, List[Route]]
+    _fallback: Any
+
+    def get_match(self, request: Request) -> Optional[RouteMatch]:
+        for route in self.routes[ensure_bytes(request.method)]:
+            match = route.match(request)
+
+            if match:
+                return match
+
+        if self._fallback is None:
+            return None
+
+        return RouteMatch(self._fallback, None)
+
+
+RouteConfig = Union[Dict[str, Any], "Router"]
 
 
 class Router(RouterBase):
-    __slots__ = ("routes", "_map", "_fallback")
+    __slots__ = ("routes", "_map", "_fallback", "_sub_routers", "_filters")
+
+    def __init__(
+        self,
+        *,
+        host: Optional[str] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
+        filters: Optional[List[RouteFilter]] = None,
+        sub_routers: Optional[List["Router"]] = None,
+    ):
+        super().__init__(
+            host=host,
+            headers=headers,
+            params=params,
+            filters=filters,
+        )
 
-    def __init__(self):
         self._map = {}
         self._fallback = None
         self.routes: Dict[bytes, List[Route]] = defaultdict(list)
+        self._sub_routers = sub_routers
+
+        if self._filters:
+            extend(self, RouterFiltersMixin)
+
+        if self._sub_routers:
+            extend(self, MultiRouterMixin)
 
     @property
     def fallback(self):
@@ -404,10 +641,18 @@ class Router(RouterBase):
             yield self._fallback
 
     def _is_route_configured(self, method: bytes, route: Route):
+        if isinstance(route, FilterRoute):  # pragma: no cover
+            # The route defines action filters, we cannot determine if the user is
+            # registering twice the same pattern. However, the user opted-in for an
+            # advanced feature and should be aware about conflicting routes.
+            return False
+
         method_patterns = self._map.get(method)
         if not method_patterns:
             return False
-        if method_patterns.get(route.full_pattern):
+        existing_route: Route = method_patterns.get(route.full_pattern)
+
+        if existing_route and not isinstance(existing_route, FilterRoute):
             return True
         return False
 
@@ -420,15 +665,33 @@ class Router(RouterBase):
 
     def _check_duplicate(self, method: bytes, new_route: Route):
         if self._is_route_configured(method, new_route):
-            current_route = self._map.get(method).get(new_route.full_pattern)
+            current_route = self._map[method][new_route.full_pattern]
             raise RouteDuplicate(method, new_route.pattern, current_route.handler)
         self._set_configured_route(method, new_route)
 
-    def add(self, method: str, pattern: AnyStr, handler: Any):
+    def add(
+        self,
+        method: str,
+        pattern: AnyStr,
+        handler: Any,
+        filters: Optional[List[RouteFilter]] = None,
+    ):
+        if filters and not isinstance(self, RouterFiltersMixin):
+            extend(self, RouterFiltersMixin)
+
+        route_filters = filters or self._filters
+
         self.mark_handler(handler)
         method_name = ensure_bytes(method)
-        new_route = Route(ensure_bytes(pattern), handler)
-        self._check_duplicate(method_name, new_route)
+        new_route = (
+            FilterRoute(ensure_bytes(pattern), handler, route_filters)
+            if route_filters
+            else Route(ensure_bytes(pattern), handler)
+        )
+
+        if not route_filters:
+            self._check_duplicate(method_name, new_route)
+
         self.add_route(method_name, new_route)
 
     def add_route(self, method: AnyStr, route: Route):
@@ -445,16 +708,34 @@ class Router(RouterBase):
             current_routes[method].sort(key=lambda route: -route.pattern.count(b"/"))
             current_routes[method].sort(key=lambda route: len(route.param_names))
             current_routes[method].sort(key=lambda route: b".*" in route.rx.pattern)
+            current_routes[method].sort(
+                key=lambda route: (
+                    -len(route.filters) if isinstance(route, FilterRoute) else 0
+                )
+            )
             current_routes[method].sort(
                 key=lambda route: b"*" == route.pattern or b"/*" == route.pattern
             )
 
         self.routes = current_routes
 
+        if self._sub_routers:
+            for sub_router in self._sub_routers:
+                sub_router.sort_routes()
+
+    def get_match(self, request: Request) -> Optional[RouteMatch]:
+        """
+        Gets a match for the given request, by method and request path.
+        """
+        return self.get_match_by_method_and_path(request.method, request._path)
+
     @lru_cache(maxsize=1200)
-    def get_match(self, method: AnyStr, value: AnyStr) -> Optional[RouteMatch]:
+    def get_match_by_method_and_path(
+        self, method: AnyStr, path: AnyStr
+    ) -> Optional[RouteMatch]:
+        bytes_value = ensure_bytes(path)
         for route in self.routes[ensure_bytes(method)]:
-            match = route.match(ensure_bytes(value))
+            match = route.match_by_path(bytes_value)
             if match:
                 return match
         if self._fallback is None:
@@ -463,12 +744,13 @@ class Router(RouterBase):
         return RouteMatch(self._fallback, None)
 
     def get_ws_match(self, value: AnyStr) -> Optional[RouteMatch]:
-        return self.get_match(RouteMethod.GET_WS, value)
+        # TODO: this does not suppor filters
+        return self.get_match_by_method_and_path(RouteMethod.GET_WS, value)
 
     @lru_cache(maxsize=1200)
     def get_matching_route(self, method: AnyStr, value: AnyStr) -> Optional[Route]:
         for route in self.routes[ensure_bytes(method)]:
-            match = route.match(ensure_bytes(value))
+            match = route.match_by_path(ensure_bytes(value))
             if match:
                 return route
         return None
@@ -477,7 +759,12 @@ class Router(RouterBase):
 class RegisteredRoute:
     __slots__ = ("method", "pattern", "handler")
 
-    def __init__(self, method: str, pattern: str, handler: Callable):
+    def __init__(
+        self,
+        method: str,
+        pattern: str,
+        handler: Callable,
+    ):
         self.method = method
         self.pattern = pattern
         self.handler = handler
@@ -492,13 +779,28 @@ class RoutesRegistry(RouterBase):
     This class is meant to enable scenarios like base pattern for controllers.
     """
 
-    def __init__(self):
+    __slots__ = ("routes", "_filters")
+
+    def __init__(
+        self,
+        *,
+        host: Optional[str] = None,
+        headers: Optional[HeadersType] = None,
+        params: Optional[ParamsType] = None,
+        filters: Optional[List[RouteFilter]] = None,
+    ):
+        super().__init__(host=host, headers=headers, params=params, filters=filters)
         self.routes: List[RegisteredRoute] = []
 
     def __iter__(self):
         yield from self.routes
 
-    def add(self, method: str, pattern: str, handler: Callable):
+    def add(
+        self,
+        method: str,
+        pattern: str,
+        handler: Callable,
+    ):
         self.mark_handler(handler)
         self.routes.append(RegisteredRoute(method, pattern, handler))
 
diff --git a/blacksheep/url.pxd b/blacksheep/url.pxd
index 7ef15d7..72fba47 100644
--- a/blacksheep/url.pxd
+++ b/blacksheep/url.pxd
@@ -20,6 +20,7 @@ cdef class URL:
     cpdef URL base_url(self)
     cpdef URL with_host(self, bytes host)
     cpdef URL with_scheme(self, bytes schema)
+    cpdef URL with_query(self, bytes query)
 
 
 cpdef URL build_absolute_url(
diff --git a/blacksheep/url.pyi b/blacksheep/url.pyi
index 46db141..7c88acf 100644
--- a/blacksheep/url.pyi
+++ b/blacksheep/url.pyi
@@ -15,7 +15,8 @@ class URL:
     def __init__(self, value: bytes) -> None: ...
     def join(self, other: "URL") -> "URL": ...
     def base_url(self) -> "URL": ...
-    def with_host(self, value: bytes) -> "URL": ...
-    def with_scheme(self, value: bytes) -> "URL": ...
+    def with_host(self, host: bytes) -> "URL": ...
+    def with_scheme(self, scheme: bytes) -> "URL": ...
+    def with_query(self, query: bytes) -> "URL": ...
     def __add__(self, other: Union[bytes, "URL"]) -> "URL": ...
     def __eq__(self, other: object) -> bool: ...
diff --git a/blacksheep/url.pyx b/blacksheep/url.pyx
index ad8bb00..a0870b1 100644
--- a/blacksheep/url.pyx
+++ b/blacksheep/url.pyx
@@ -70,12 +70,21 @@ cdef class URL:
         return URL(base_url)
 
     cpdef URL with_host(self, bytes host):
+        cdef bytes query, fragment
         if not self.is_absolute:
             raise TypeError("Cannot generate a URL from a partial URL")
         query = b"?" + self.query if self.query else b""
         fragment = b"#" + self.fragment if self.fragment else b""
         return URL(self.schema + b"://" + host + self.path + query + fragment)
 
+    cpdef URL with_query(self, bytes query):
+        cdef bytes fragment
+        query = b"?" + query if query else b""
+        fragment = b"#" + self.fragment if self.fragment else b""
+        if self.is_absolute:
+            return URL(self.schema + b"://" + self.host + self.path + query + fragment)
+        return URL(self.path + query + fragment)
+
     cpdef URL with_scheme(self, bytes schema):
         valid_schema(schema)
 
diff --git a/requirements.txt b/requirements.txt
index e7f7c4e..99f5bd0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,7 +10,7 @@ click==8.1.3
 coverage==6.5.0
 Cython==0.29.32; platform_system != "Windows"
 essentials==1.1.5
-essentials-openapi==1.0.5
+essentials-openapi==1.0.6
 Flask==2.2.2
 gevent==22.10.1
 greenlet==1.1.3.post0
@@ -42,7 +42,7 @@ python-dateutil==2.8.2
 PyYAML==6.0
 regex==2020.4.4
 requests==2.28.1
-rodi~=2.0.0
+rodi~=2.0.2
 six==1.16.0
 toml==0.10.1
 tomli==2.0.1
diff --git a/setup.py b/setup.py
index ac84dac..888dd81 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@ COMPILE_ARGS = ["-O2"]
 
 setup(
     name="blacksheep",
-    version="2.0a4",
+    version="2.0a5",
     description="Fast web framework for Python asyncio",
     long_description=readme(),
     long_description_content_type="text/markdown",
@@ -100,9 +100,9 @@ setup(
         "cchardet~=2.1.7; python_version < '3.11'",
         "chardet==5.0.0; python_version > '3.10'",
         "guardpost~=1.0.0",
-        "rodi~=2.0.0",
+        "rodi~=2.0.2",
         "essentials>=1.1.4,<2.0",
-        "essentials-openapi>=0.1.4,<1.1",
+        "essentials-openapi>=1.0.6,<1.1",
         "typing_extensions; python_version < '3.8'",
         "python-dateutil~=2.8.2",
         "itsdangerous~=2.1.2",


Test patch:
diff --git a/tests/test_caching.py b/tests/test_caching.py
new file mode 100644
index 0000000..b6add28
--- /dev/null
+++ b/tests/test_caching.py
@@ -0,0 +1,105 @@
+import pytest
+
+from blacksheep.server.controllers import Controller
+from blacksheep.server.headers.cache import (
+    CacheControlMiddleware,
+    cache_control,
+    write_cache_control_response_header,
+)
+from blacksheep.server.routing import RoutesRegistry
+from blacksheep.testing.helpers import get_example_scope
+from blacksheep.testing.messages import MockReceive, MockSend
+
+CACHE_CONTROL_PARAMS_EXPECTED = [
+    ({"max_age": 0}, b"max-age=0"),
+    ({"max_age": 120}, b"max-age=120"),
+    ({"shared_max_age": 604800}, b"s-maxage=604800"),
+    ({"no_cache": True}, b"no-cache"),
+    ({"no_store": True}, b"no-store"),
+    ({"must_understand": True, "no_store": True}, b"no-store, must-understand"),
+    ({"private": True}, b"private"),
+    ({"public": True}, b"public"),
+    ({"no_cache": True, "no_store": True}, b"no-cache, no-store"),
+    ({"max_age": 0, "must_revalidate": True}, b"max-age=0, must-revalidate"),
+    ({"max_age": 0, "proxy_revalidate": True}, b"max-age=0, proxy-revalidate"),
+    ({"no_transform": True}, b"no-transform"),
+    (
+        {"public": True, "max_age": 604800, "immutable": True},
+        b"public, max-age=604800, immutable",
+    ),
+    (
+        {"max_age": 604800, "stale_while_revalidate": 86400},
+        b"max-age=604800, stale-while-revalidate=86400",
+    ),
+    (
+        {"max_age": 604800, "stale_if_error": 86400},
+        b"max-age=604800, stale-if-error=86400",
+    ),
+]
+
+
+async def _assert_scenario(app, expected_header: bytes):
+    @app.router.get("/no-cache")
+    @cache_control(no_cache=True, no_store=True)
+    def example_no():
+        return "Example"
+
+    await app.start()
+    await app(get_example_scope("GET", "/", []), MockReceive(), MockSend())
+
+    response = app.response
+    assert response.status == 200
+    cache_control_value = response.headers[b"cache-control"]
+    assert len(cache_control_value) == 1
+    assert cache_control_value[0] == expected_header
+
+    await app(get_example_scope("GET", "/no-cache", []), MockReceive(), MockSend())
+
+    response = app.response
+    assert response.status == 200
+    cache_control_value = response.headers[b"cache-control"]
+    assert len(cache_control_value) == 1
+    assert cache_control_value[0] == b"no-cache, no-store"
+
+
+def test_write_cache_control_response_header_raises_for_priv_pub():
+    with pytest.raises(ValueError):
+        write_cache_control_response_header(private=True, public=True)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("params,expected_header", CACHE_CONTROL_PARAMS_EXPECTED)
+async def test_cache_control_decorator(app, params, expected_header):
+    @app.router.get("/")
+    @cache_control(**params)
+    def example():
+        return "Example"
+
+    await _assert_scenario(app, expected_header)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("params,expected_header", CACHE_CONTROL_PARAMS_EXPECTED)
+async def test_cache_control_in_controller(app, params, expected_header):
+    app.controllers_router = RoutesRegistry()
+    get = app.controllers_router.get
+
+    class Home(Controller):
+        @get("/")
+        @cache_control(**params)
+        async def index(self):
+            return "Example"
+
+    await _assert_scenario(app, expected_header)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("params,expected_header", CACHE_CONTROL_PARAMS_EXPECTED)
+async def test_cache_control_middleware(app, params, expected_header):
+    app.middlewares.append(CacheControlMiddleware(**params))
+
+    @app.router.get("/")
+    def example():
+        return "Example"
+
+    await _assert_scenario(app, expected_header)
diff --git a/tests/test_controllers.py b/tests/test_controllers.py
index 574ba40..f82aba0 100644
--- a/tests/test_controllers.py
+++ b/tests/test_controllers.py
@@ -8,7 +8,12 @@ from rodi import inject
 
 from blacksheep import Request, Response
 from blacksheep.server.application import Application
-from blacksheep.server.controllers import APIController, Controller, RoutesRegistry
+from blacksheep.server.controllers import (
+    APIController,
+    Controller,
+    RoutesRegistry,
+    filters,
+)
 from blacksheep.server.responses import text
 from blacksheep.server.routing import RouteDuplicate
 from blacksheep.server.websocket import WebSocket
@@ -908,3 +913,30 @@ async def test_handler_through_controller_default_str(app):
     assert app.response.status == 200
     data = await app.response.text()
     assert data == "Hello World"
+
+
+@pytest.mark.asyncio
+async def test_controller_filters(app):
+    app.controllers_router = RoutesRegistry()
+    get = app.controllers_router.get
+
+    @filters(headers={"X-Area": "51"})
+    class Home(Controller):
+        @get("/")
+        async def index(self) -> str:
+            return "Hello World"
+
+    app.setup_controllers()
+    await app(get_example_scope("GET", "/"), MockReceive(), MockSend())
+
+    assert app.response.status == 404
+
+    await app(
+        get_example_scope("GET", "/", extra_headers={"X-Area": "51"}),
+        MockReceive(),
+        MockSend(),
+    )
+
+    assert app.response.status == 200
+    data = await app.response.text()
+    assert data == "Hello World"
diff --git a/tests/test_router.py b/tests/test_router.py
index a8b4e60..6b8c29f 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -1,14 +1,20 @@
+from typing import List
+
 import pytest
 
+from blacksheep.messages import Request
 from blacksheep.server.application import Application
 from blacksheep.server.routing import (
+    HostFilter,
     InvalidValuePatternName,
     MountRegistry,
     Route,
     RouteDuplicate,
     RouteException,
+    RouteFilter,
     RouteMethod,
     Router,
+    normalize_filters,
 )
 
 FAKE = b"FAKE"
@@ -73,7 +79,7 @@ class MockHandler:
 def test_route_good_matches(pattern, url, expected_values):
     route = Route(pattern, mock_handler)
     print(route.full_pattern)
-    match = route.match(url)
+    match = route.match_by_path(url)
 
     assert match is not None
     assert match.values == expected_values
@@ -125,7 +131,7 @@ def test_route_good_matches(pattern, url, expected_values):
 )
 def test_route_good_matches_with_parameter_patterns(pattern, url, expected_values):
     route = Route(pattern, mock_handler)
-    match = route.match(url)
+    match = route.match_by_path(url)
 
     assert match is not None
     assert match.values == expected_values
@@ -144,7 +150,7 @@ def test_route_good_matches_with_parameter_patterns(pattern, url, expected_value
 )
 def test_route_bad_matches_with_parameter_patterns(pattern, url):
     route = Route(pattern, mock_handler)
-    match = route.match(url)
+    match = route.match_by_path(url)
     assert match is None
 
 
@@ -192,7 +198,7 @@ def test_route_raises_for_invalid_parameter_name(pattern, invalid_pattern_name):
 )
 def test_route_repr(pattern: str):
     route = Route(pattern, mock_handler)
-    assert repr(route) == f"<Route {pattern}>"
+    assert repr(route) == f'<Route "{pattern}">'
 
 
 @pytest.mark.parametrize(
@@ -205,7 +211,7 @@ def test_route_repr(pattern: str):
 )
 def test_route_bad_matches(pattern, url):
     route = Route(pattern, mock_handler)
-    match = route.match(url)
+    match = route.match_by_path(url)
 
     assert match is None
 
@@ -226,7 +232,7 @@ def test_route_handler_can_be_anything():
     handler = MockHandler(request_handler, auth_handler)
 
     route = Route(b"/", handler)
-    match = route.match(b"/")
+    match = route.match_by_path(b"/")
 
     assert match is not None
     assert match.handler.request_handler is request_handler
@@ -238,7 +244,7 @@ def test_router_add_method(method, pattern, url):
     router = Router()
     router.add(method, pattern, mock_handler)
 
-    match = router.get_match(method, url)
+    match = router.get_match_by_method_and_path(method, url)
 
     assert match is not None
     assert match.handler is mock_handler
@@ -246,7 +252,7 @@ def test_router_add_method(method, pattern, url):
     route = router.get_matching_route(method, url)
     assert route is not None
 
-    match = router.get_match(FAKE, url)
+    match = router.get_match_by_method_and_path(FAKE, url)
     assert match is None
 
     route = router.get_matching_route(FAKE, url)
@@ -257,7 +263,7 @@ def test_router_add_method(method, pattern, url):
 def test_router_not_matching_routes(method, pattern, url):
     router = Router()
     router.add(method, pattern, mock_handler)
-    route = router.get_match(method, url)
+    route = router.get_match_by_method_and_path(method, url)
     assert route is None
 
 
@@ -272,7 +278,7 @@ def test_router_add_shortcuts(method, pattern, url):
 
     fn(pattern, home)
 
-    route = router.get_match(method.upper(), url)
+    route = router.get_match_by_method_and_path(method.upper(), url)
 
     assert route is not None
     assert route.handler is home
@@ -280,7 +286,7 @@ def test_router_add_shortcuts(method, pattern, url):
     value = route.handler()
     assert value == "Hello, World"
 
-    route = router.get_match(FAKE, url)
+    route = router.get_match_by_method_and_path(FAKE, url)
     assert route is None
 
 
@@ -294,7 +300,7 @@ def test_router_decorator(decorator, pattern, url):
     def home():
         return "Hello, World"
 
-    route = router.get_match(decorator.upper(), url)
+    route = router.get_match_by_method_and_path(decorator.upper(), url)
 
     assert route is not None
     assert route.handler is home
@@ -302,7 +308,7 @@ def test_router_decorator(decorator, pattern, url):
     value = route.handler()
     assert value == "Hello, World"
 
-    route = router.get_match(FAKE, url)
+    route = router.get_match_by_method_and_path(FAKE, url)
     assert route is None
 
 
@@ -318,15 +324,15 @@ def test_router_match_any_by_extension():
     router.add_get("/a/*.js", a)
     router.add_get("/b/*.css", b)
 
-    m = router.get_match(RouteMethod.GET, b"/a/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/a/anything/really")
     assert m is None
 
-    m = router.get_match(RouteMethod.GET, b"/a/anything/really.js")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/a/anything/really.js")
     assert m is not None
     assert m.handler is a
     assert m.values.get("tail") == "anything/really"
 
-    m = router.get_match(RouteMethod.GET, b"/b/anything/really.css")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/b/anything/really.css")
     assert m is not None
     assert m.handler is b
     assert m.values.get("tail") == "anything/really"
@@ -352,46 +358,46 @@ def test_router_match_any_below():
     router.add_get("/c/*", c)
     router.add_get("/d/*", d)
 
-    m = router.get_match(RouteMethod.GET, b"/a")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/a")
     assert m is not None
     assert m.handler is a
     assert m.values.get("tail") == ""
 
-    m = router.get_match(RouteMethod.GET, b"/a/")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/a/")
     assert m is not None
     assert m.handler is a
     assert m.values.get("tail") == ""
 
-    m = router.get_match(RouteMethod.GET, b"/a/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/a/anything/really")
     assert m is not None
     assert m.handler is a
     assert m.values.get("tail") == "anything/really"
 
-    m = router.get_match(RouteMethod.GET, b"/b/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/b/anything/really")
     assert m is not None
     assert m.handler is b
     assert m.values.get("tail") == "anything/really"
 
-    m = router.get_match(RouteMethod.GET, b"/c/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/c/anything/really")
     assert m is not None
     assert m.handler is c
     assert m.values.get("tail") == "anything/really"
 
-    m = router.get_match(RouteMethod.GET, b"/d/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/d/anything/really")
     assert m is not None
     assert m.handler is d
     assert m.values.get("tail") == "anything/really"
 
-    m = router.get_match(RouteMethod.POST, b"/a/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/a/anything/really")
     assert m is None
 
-    m = router.get_match(RouteMethod.POST, b"/b/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/b/anything/really")
     assert m is None
 
-    m = router.get_match(RouteMethod.POST, b"/c/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/c/anything/really")
     assert m is None
 
-    m = router.get_match(RouteMethod.POST, b"/d/anything/really")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/d/anything/really")
     assert m is None
 
 
@@ -435,42 +441,42 @@ def test_router_match_among_many():
     router.add_delete("/foo", delete_foo)
     router.add_ws("/ws", ws)
 
-    m = router.get_match(RouteMethod.GET, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/")
     assert m is not None
     assert m.handler is home
 
-    m = router.get_match(RouteMethod.TRACE, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.TRACE, b"/")
     assert m is not None
     assert m.handler is home_verbose
 
-    m = router.get_match(RouteMethod.CONNECT, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.CONNECT, b"/")
     assert m is not None
     assert m.handler is home_connect
 
-    m = router.get_match(RouteMethod.OPTIONS, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.OPTIONS, b"/")
     assert m is not None
     assert m.handler is home_options
 
-    m = router.get_match(RouteMethod.POST, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/")
     assert m is None
 
-    m = router.get_match(RouteMethod.GET, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/foo")
     assert m is not None
     assert m.handler is get_foo
 
-    m = router.get_match(RouteMethod.POST, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/foo")
     assert m is not None
     assert m.handler is create_foo
 
-    m = router.get_match(RouteMethod.PATCH, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.PATCH, b"/foo")
     assert m is not None
     assert m.handler is patch_foo
 
-    m = router.get_match(RouteMethod.DELETE, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.DELETE, b"/foo")
     assert m is not None
     assert m.handler is delete_foo
 
-    m = router.get_match(RouteMethod.GET_WS, b"/ws")
+    m = router.get_match_by_method_and_path(RouteMethod.GET_WS, b"/ws")
     assert m is not None
     assert m.handler is ws
 
@@ -487,11 +493,11 @@ def test_router_match_ws_get_sharing_path():
     router.add_get("/", home)
     router.add_ws("/", ws)
 
-    m = router.get_match(RouteMethod.GET, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/")
     assert m is not None
     assert m.handler is home
 
-    m = router.get_match(RouteMethod.GET_WS, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.GET_WS, b"/")
     assert m is not None
     assert m.handler is ws
 
@@ -531,38 +537,38 @@ def test_router_match_among_many_decorators():
     def delete_foo():
         ...
 
-    m = router.get_match(RouteMethod.GET, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/")
     assert m is not None
     assert m.handler is home
 
-    m = router.get_match(RouteMethod.TRACE, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.TRACE, b"/")
     assert m is not None
     assert m.handler is home_verbose
 
-    m = router.get_match(RouteMethod.CONNECT, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.CONNECT, b"/")
     assert m is not None
     assert m.handler is home_connect
 
-    m = router.get_match(RouteMethod.OPTIONS, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.OPTIONS, b"/")
     assert m is not None
     assert m.handler is home_options
 
-    m = router.get_match(RouteMethod.POST, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/")
     assert m is None
 
-    m = router.get_match(RouteMethod.GET, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/foo")
     assert m is not None
     assert m.handler is get_foo
 
-    m = router.get_match(RouteMethod.POST, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/foo")
     assert m is not None
     assert m.handler is create_foo
 
-    m = router.get_match(RouteMethod.PATCH, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.PATCH, b"/foo")
     assert m is not None
     assert m.handler is patch_foo
 
-    m = router.get_match(RouteMethod.DELETE, b"/foo")
+    m = router.get_match_by_method_and_path(RouteMethod.DELETE, b"/foo")
     assert m is not None
     assert m.handler is delete_foo
 
@@ -579,17 +585,17 @@ def test_router_match_with_trailing_slash():
     router.add_get("/foo", get_foo)
     router.add_post("/foo", create_foo)
 
-    m = router.get_match(RouteMethod.GET, b"/foo/")
+    m = router.get_match_by_method_and_path(RouteMethod.GET, b"/foo/")
 
     assert m is not None
     assert m.handler is get_foo
 
-    m = router.get_match(RouteMethod.POST, b"/foo/")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/foo/")
 
     assert m is not None
     assert m.handler is create_foo
 
-    m = router.get_match(RouteMethod.POST, b"/foo//")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/foo//")
 
     assert m is None
 
@@ -604,7 +610,7 @@ def test_fallback_route():
     assert isinstance(router.fallback, Route)
     assert router.fallback.handler is not_found_handler
 
-    m = router.get_match(RouteMethod.POST, b"/")
+    m = router.get_match_by_method_and_path(RouteMethod.POST, b"/")
 
     assert m is not None
     assert m.handler is not_found_handler
@@ -701,15 +707,15 @@ def test_automatic_pattern_with_ellipsis():
     def another():
         ...
 
-    match = router.get_match("GET", "/")
+    match = router.get_match_by_method_and_path("GET", "/")
     assert match is None
 
-    match = router.get_match("GET", "/home")
+    match = router.get_match_by_method_and_path("GET", "/home")
 
     assert match is not None
     assert match.handler is home
 
-    match = router.get_match("GET", "/another")
+    match = router.get_match_by_method_and_path("GET", "/another")
 
     assert match is not None
     assert match.handler is another
@@ -722,11 +728,11 @@ def test_automatic_pattern_with_ellipsis_name_normalization():
     def hello_world():
         ...
 
-    match = router.get_match("GET", "/hello_world")
+    match = router.get_match_by_method_and_path("GET", "/hello_world")
 
     assert match is None
 
-    match = router.get_match("GET", "/hello-world")
+    match = router.get_match_by_method_and_path("GET", "/hello-world")
 
     assert match is not None
     assert match.handler is hello_world
@@ -739,7 +745,7 @@ def test_automatic_pattern_with_ellipsis_index_name():
     def index():
         ...
 
-    match = router.get_match("GET", "/")
+    match = router.get_match_by_method_and_path("GET", "/")
 
     assert match is not None
     assert match.handler is index
@@ -860,5 +866,231 @@ def test_mount_mounted_paths():
 def test_mount_add_raise_error_if_path_exist():
     with pytest.raises(AssertionError):
         mount = MountRegistry()
-        mount.mount("/foo", None)
-        mount.mount("/foo", None)
+        mount.mount("/foo", None)  # type: ignore
+        mount.mount("/foo", None)  # type: ignore
+
+
+def test_route_filter_headers_1():
+    router = Router(headers={"X-Test": "Test"})
+
+    @router.get("/")
+    def home():
+        ...
+
+    match = router.get_match(Request("GET", b"/", []))
+
+    assert match is None
+
+    match = router.get_match(Request("GET", b"/foo", []))
+
+    assert match is None
+
+    match = router.get_match(Request("GET", b"/", [(b"X-Test", b"Test")]))
+
+    assert match is not None
+
+
+def test_route_filter_fallback():
+    router = Router(headers={"X-Test": "Test"})
+
+    @router.get("/")
+    def home():
+        ...
+
+    def fallback():
+        ...
+
+    router.fallback = fallback
+    match = router.get_match(Request("GET", b"/", []))
+
+    assert match is not None
+    assert match.handler is fallback
+
+
+def test_route_filter_headers_2():
+    test_router = Router(headers={"X-Test": "Test"})
+    router = Router(sub_routers=[test_router])
+
+    @router.get("/")
+    def home():
+        ...
+
+    @test_router.get("/")
+    def test_home():
+        ...
+
+    match = router.get_match(Request("GET", b"/", []))
+
+    assert match is not None
+    assert match.handler is home
+
+    match = router.get_match(Request("GET", b"/", [(b"X-Test", b"Test")]))
+
+    assert match is not None
+    assert match.handler is test_home
+
+
+def test_route_filter_params_1():
+    router = Router(params={"foo": "1"})
+
+    @router.get("/")
+    def home():
+        ...
+
+    match = router.get_match(Request("GET", b"/", []))
+
+    assert match is None
+
+    match = router.get_match(Request("GET", b"/?foo=1", []))
+
+    assert match is not None
+
+    match = router.get_match(Request("GET", b"/?foo=2", []))
+
+    assert match is None
+
+
+def test_route_filter_host():
+    router = Router(host="neoteroi.dev")
+
+    @router.get("/")
+    def test_home():
+        ...
+
+    match = router.get_match(Request("GET", b"/", [(b"host", b"localhost")]))
+
+    assert match is None
+
+    for host_value in {b"neoteroi.dev", b"NEOTEROI.DEV", b"neoteroi.dev:3000"}:
+        match = router.get_match(Request("GET", b"/", [(b"host", host_value)]))
+
+        assert match is not None
+        assert match.handler is test_home
+
+
+def test_route_custom_filter():
+    class TimeFilter(RouteFilter):
+        def __init__(self) -> None:
+            self._counter = -1
+
+        def handle(self, request: Request) -> bool:
+            self._counter += 1
+
+            return self._counter % 2 == 0
+
+    router = Router(filters=[TimeFilter()])
+
+    @router.get("/")
+    def test_home():
+        ...
+
+    for i in range(5):
+        match = router.get_match(Request("GET", b"/", []))
+
+        if i % 2 == 0:
+            assert match is not None
+        else:
+            assert match is None
+
+
+def test_host_filter_props():
+    host_filter = HostFilter("www.neoteroi.dev")
+    assert host_filter.host == "www.neoteroi.dev"
+
+
+def test_normalize_filters():
+    class CustomFilter(RouteFilter):
+        def handle(self, request: Request) -> bool:
+            return True
+
+    input_filters: List[RouteFilter] = [CustomFilter()]
+    all_filters = normalize_filters(
+        host="www.neoteroi.dev", headers={"X-Foo": "foo"}, filters=input_filters
+    )
+
+    assert len(input_filters) == 1
+    assert len(all_filters) == 3
+
+
+def test_sub_routers_iter():
+    test_router = Router(headers={"X-Test": "Test"})
+    router = Router(sub_routers=[test_router])
+
+    @router.get("/")
+    def home():
+        ...
+
+    @router.post("/foo")
+    def post_foo():
+        ...
+
+    @test_router.get("/")
+    def test_home():
+        ...
+
+    @test_router.post("/cats")
+    def post_cat():
+        ...
+
+    routes = list(router)
+    assert len(routes) == 4
+    handlers = [route.handler for route in routes]
+    assert set(handlers) == {home, post_foo, test_home, post_cat}
+
+
+def test_sub_routers_sort():
+    test_router = Router(headers={"X-Test": "Test"})
+    router = Router(sub_routers=[test_router])
+
+    @router.get("/")
+    def home():
+        ...
+
+    @router.post("/foo")
+    def post_foo():
+        ...
+
+    @test_router.get("/")
+    def test_home():
+        ...
+
+    @test_router.post("/cats")
+    def post_cat():
+        ...
+
+    router.sort_routes()
+
+    routes = list(router)
+    assert len(routes) == 4
+    handlers = [route.handler for route in routes]
+    assert handlers == [home, post_foo, test_home, post_cat]
+
+
+def test_routes_with_filters_can_have_duplicates():
+    """
+    Verifies that the router does not prevent registering multiple routes for the same
+    method and path, when they have filters.
+    """
+    test_router = Router(headers={"X-Test": "Test"})
+    router = Router(sub_routers=[test_router])
+
+    @router.get("/")
+    def home():
+        ...
+
+    @router.post("/foo")
+    def post_foo():
+        ...
+
+    @test_router.get("/")
+    def test_home():
+        ...
+
+    @test_router.post("/cats")
+    def post_cat():
+        ...
+
+    routes = list(router)
+    assert len(routes) == 4
+    handlers = [route.handler for route in routes]
+    assert set(handlers) == {home, post_foo, test_home, post_cat}