diff --git a/docs/sanic/api_reference.rst b/docs/sanic/api_reference.rst
index 5ca7556a..8b8dc04a 100644
--- a/docs/sanic/api_reference.rst
+++ b/docs/sanic/api_reference.rst
@@ -20,6 +20,15 @@ sanic.blueprints module
:undoc-members:
:show-inheritance:
+sanic.blueprint_group module
+----------------------------
+
+.. automodule:: sanic.blueprint_group
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+
sanic.config module
-------------------
diff --git a/docs/sanic/blueprints.md b/docs/sanic/blueprints.md
index e38cf4b1..6edbf1a8 100644
--- a/docs/sanic/blueprints.md
+++ b/docs/sanic/blueprints.md
@@ -127,7 +127,7 @@ Blueprints have almost the same functionality as an application instance.
WebSocket handlers can be registered on a blueprint using the `@bp.websocket`
decorator or `bp.add_websocket_route` method.
-### Middleware
+### Blueprint Middleware
Using blueprints allows you to also register middleware globally.
@@ -145,6 +145,36 @@ async def halt_response(request, response):
return text('I halted the response')
```
+### Blueprint Group Middleware
+Using this middleware will ensure that you can apply a common middleware to all the blueprints that form the
+current blueprint group under consideration.
+
+```python
+bp1 = Blueprint('bp1', url_prefix='/bp1')
+bp2 = Blueprint('bp2', url_prefix='/bp2')
+
+@bp1.middleware('request')
+async def bp1_only_middleware(request):
+ print('applied on Blueprint : bp1 Only')
+
+@bp1.route('/')
+async def bp1_route(request):
+ return text('bp1')
+
+@bp2.route('/')
+async def bp2_route(request, param):
+ return text(param)
+
+group = Blueprint.group(bp1, bp2)
+
+@group.middleware('request')
+async def group_middleware(request):
+ print('common middleware applied for both bp1 and bp2')
+
+# Register Blueprint group under the app
+app.blueprint(group)
+```
+
### Exceptions
Exceptions can be applied exclusively to blueprints globally.
diff --git a/sanic/app.py b/sanic/app.py
index e7ef89e0..a32d924c 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -15,6 +15,7 @@ from typing import Any, Optional, Type, Union
from urllib.parse import urlencode, urlunparse
from sanic import reloader_helpers
+from sanic.blueprint_group import BlueprintGroup
from sanic.config import BASE_LOGO, Config
from sanic.constants import HTTP_METHODS
from sanic.exceptions import SanicException, ServerError, URLBuildError
@@ -597,9 +598,11 @@ class Sanic:
:return: decorated method
"""
if attach_to == "request":
- self.request_middleware.append(middleware)
+ if middleware not in self.request_middleware:
+ self.request_middleware.append(middleware)
if attach_to == "response":
- self.response_middleware.appendleft(middleware)
+ if middleware not in self.response_middleware:
+ self.response_middleware.appendleft(middleware)
return middleware
# Decorator
@@ -681,7 +684,7 @@ class Sanic:
:param options: option dictionary with blueprint defaults
:return: Nothing
"""
- if isinstance(blueprint, (list, tuple)):
+ if isinstance(blueprint, (list, tuple, BlueprintGroup)):
for item in blueprint:
self.blueprint(item, **options)
return
diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py
new file mode 100644
index 00000000..9519d4f0
--- /dev/null
+++ b/sanic/blueprint_group.py
@@ -0,0 +1,120 @@
+from collections import MutableSequence
+
+
+class BlueprintGroup(MutableSequence):
+ """
+ This class provides a mechanism to implement a Blueprint Group
+ using the `Blueprint.group` method. To avoid having to re-write
+ some of the existing implementation, this class provides a custom
+ iterator implementation that will let you use the object of this
+ class as a list/tuple inside the existing implementation.
+ """
+
+ __slots__ = ("_blueprints", "_url_prefix")
+
+ def __init__(self, url_prefix=None):
+ """
+ Create a new Blueprint Group
+
+ :param url_prefix: URL: to be prefixed before all the Blueprint Prefix
+ """
+ self._blueprints = []
+ self._url_prefix = url_prefix
+
+ @property
+ def url_prefix(self):
+ """
+ Retrieve the URL prefix being used for the Current Blueprint Group
+ :return: string with url prefix
+ """
+ return self._url_prefix
+
+ @property
+ def blueprints(self):
+ """
+ Retrieve a list of all the available blueprints under this group.
+ :return: List of Blueprint instance
+ """
+ return self._blueprints
+
+ def __iter__(self):
+ """Tun the class Blueprint Group into an Iterable item"""
+ return iter(self._blueprints)
+
+ def __getitem__(self, item):
+ """
+ This method returns a blueprint inside the group specified by
+ an index value. This will enable indexing, splice and slicing
+ of the blueprint group like we can do with regular list/tuple.
+
+ This method is provided to ensure backward compatibility with
+ any of the pre-existing usage that might break.
+
+ :param item: Index of the Blueprint item in the group
+ :return: Blueprint object
+ """
+ return self._blueprints[item]
+
+ def __setitem__(self, index: int, item: object) -> None:
+ """
+ Abstract method implemented to turn the `BlueprintGroup` class
+ into a list like object to support all the existing behavior.
+
+ This method is used to perform the list's indexed setter operation.
+
+ :param index: Index to use for inserting a new Blueprint item
+ :param item: New `Blueprint` object.
+ :return: None
+ """
+ self._blueprints[index] = item
+
+ def __delitem__(self, index: int) -> None:
+ """
+ Abstract method implemented to turn the `BlueprintGroup` class
+ into a list like object to support all the existing behavior.
+
+ This method is used to delete an item from the list of blueprint
+ groups like it can be done on a regular list with index.
+
+ :param index: Index to use for removing a new Blueprint item
+ :return: None
+ """
+ del self._blueprints[index]
+
+ def __len__(self) -> int:
+ """
+ Get the Length of the blueprint group object.
+ :return: Length of Blueprint group object
+ """
+ return len(self._blueprints)
+
+ def insert(self, index: int, item: object) -> None:
+ """
+ The Abstract class `MutableSequence` leverages this insert method to
+ perform the `BlueprintGroup.append` operation.
+
+ :param index: Index to use for removing a new Blueprint item
+ :param item: New `Blueprint` object.
+ :return: None
+ """
+ self._blueprints.insert(index, item)
+
+ def middleware(self, *args, **kwargs):
+ """
+ A decorator that can be used to implement a Middleware plugin to
+ all of the Blueprints that belongs to this specific Blueprint Group.
+
+ In case of nested Blueprint Groups, the same middleware is applied
+ across each of the Blueprints recursively.
+
+ :param args: Optional positional Parameters to be use middleware
+ :param kwargs: Optional Keyword arg to use with Middleware
+ :return: Partial function to apply the middleware
+ """
+ kwargs["bp_group"] = True
+
+ def register_middleware_for_blueprints(fn):
+ for blueprint in self.blueprints:
+ blueprint.middleware(fn, *args, **kwargs)
+
+ return register_middleware_for_blueprints
diff --git a/sanic/blueprints.py b/sanic/blueprints.py
index f2575e57..c50de648 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -1,5 +1,6 @@
from collections import defaultdict, namedtuple
+from sanic.blueprint_group import BlueprintGroup
from sanic.constants import HTTP_METHODS
from sanic.views import CompositionView
@@ -78,10 +79,12 @@ class Blueprint:
for i in nested:
if isinstance(i, (list, tuple)):
yield from chain(i)
+ elif isinstance(i, BlueprintGroup):
+ yield from i.blueprints
else:
yield i
- bps = []
+ bps = BlueprintGroup(url_prefix=url_prefix)
for bp in chain(blueprints):
if bp.url_prefix is None:
bp.url_prefix = ""
@@ -327,7 +330,13 @@ class Blueprint:
args = []
return register_middleware(middleware)
else:
- return register_middleware
+ if kwargs.get("bp_group") and callable(args[0]):
+ middleware = args[0]
+ args = args[1:]
+ kwargs.pop("bp_group")
+ return register_middleware(middleware)
+ else:
+ return register_middleware
def exception(self, *args, **kwargs):
"""
diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py
new file mode 100644
index 00000000..fe1db7a3
--- /dev/null
+++ b/tests/test_blueprint_group.py
@@ -0,0 +1,180 @@
+from pytest import raises
+
+from sanic.app import Sanic
+from sanic.blueprints import Blueprint
+from sanic.request import Request
+from sanic.response import text, HTTPResponse
+
+MIDDLEWARE_INVOKE_COUNTER = {"request": 0, "response": 0}
+
+AUTH = "dGVzdDp0ZXN0Cg=="
+
+
+def test_bp_group_indexing(app: Sanic):
+ blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1")
+ blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2")
+
+ group = Blueprint.group(blueprint_1, blueprint_2)
+ assert group[0] == blueprint_1
+
+ with raises(expected_exception=IndexError) as e:
+ _ = group[3]
+
+
+def test_bp_group_with_additional_route_params(app: Sanic):
+ blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1")
+ blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2")
+
+ @blueprint_1.route(
+ "/request_path", methods=frozenset({"PUT", "POST"}), version=2
+ )
+ def blueprint_1_v2_method_with_put_and_post(request: Request):
+ if request.method == "PUT":
+ return text("PUT_OK")
+ elif request.method == "POST":
+ return text("POST_OK")
+
+ @blueprint_2.route(
+ "/route/", methods=frozenset({"DELETE", "PATCH"}), name="test"
+ )
+ def blueprint_2_named_method(request: Request, param):
+ if request.method == "DELETE":
+ return text("DELETE_{}".format(param))
+ elif request.method == "PATCH":
+ return text("PATCH_{}".format(param))
+
+ blueprint_group = Blueprint.group(
+ blueprint_1, blueprint_2, url_prefix="/api"
+ )
+
+ @blueprint_group.middleware("request")
+ def authenticate_request(request: Request):
+ global AUTH
+ auth = request.headers.get("authorization")
+ if auth:
+ # Dummy auth check. We can have anything here and it's fine.
+ if AUTH not in auth:
+ return text("Unauthorized", status=401)
+ else:
+ return text("Unauthorized", status=401)
+
+ @blueprint_group.middleware("response")
+ def enhance_response_middleware(request: Request, response: HTTPResponse):
+ response.headers.add("x-test-middleware", "value")
+
+ app.blueprint(blueprint_group)
+
+ header = {"authorization": " ".join(["Basic", AUTH])}
+ _, response = app.test_client.put(
+ "/v2/api/bp1/request_path", headers=header
+ )
+ assert response.text == "PUT_OK"
+ assert response.headers.get("x-test-middleware") == "value"
+
+ _, response = app.test_client.post(
+ "/v2/api/bp1/request_path", headers=header
+ )
+ assert response.text == "POST_OK"
+
+ _, response = app.test_client.delete("/api/bp2/route/bp2", headers=header)
+ assert response.text == "DELETE_bp2"
+
+ _, response = app.test_client.patch("/api/bp2/route/bp2", headers=header)
+ assert response.text == "PATCH_bp2"
+
+ _, response = app.test_client.get("/v2/api/bp1/request_path")
+ assert response.status == 401
+
+
+def test_bp_group(app: Sanic):
+ blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1")
+ blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2")
+
+ @blueprint_1.route("/")
+ def blueprint_1_default_route(request):
+ return text("BP1_OK")
+
+ @blueprint_2.route("/")
+ def blueprint_2_default_route(request):
+ return text("BP2_OK")
+
+ blueprint_group_1 = Blueprint.group(
+ blueprint_1, blueprint_2, url_prefix="/bp"
+ )
+
+ blueprint_3 = Blueprint("blueprint_3", url_prefix="/bp3")
+
+ @blueprint_group_1.middleware("request")
+ def blueprint_group_1_middleware(request):
+ global MIDDLEWARE_INVOKE_COUNTER
+ MIDDLEWARE_INVOKE_COUNTER["request"] += 1
+
+ @blueprint_3.route("/")
+ def blueprint_3_default_route(request):
+ return text("BP3_OK")
+
+ blueprint_group_2 = Blueprint.group(
+ blueprint_group_1, blueprint_3, url_prefix="/api"
+ )
+
+ @blueprint_group_2.middleware("response")
+ def blueprint_group_2_middleware(request, response):
+ global MIDDLEWARE_INVOKE_COUNTER
+ MIDDLEWARE_INVOKE_COUNTER["response"] += 1
+
+ app.blueprint(blueprint_group_2)
+
+ @app.route("/")
+ def app_default_route(request):
+ return text("APP_OK")
+
+ _, response = app.test_client.get("/")
+ assert response.text == "APP_OK"
+
+ _, response = app.test_client.get("/api/bp/bp1")
+ assert response.text == "BP1_OK"
+
+ _, response = app.test_client.get("/api/bp/bp2")
+ assert response.text == "BP2_OK"
+
+ _, response = app.test_client.get("/api/bp3")
+ assert response.text == "BP3_OK"
+
+ assert MIDDLEWARE_INVOKE_COUNTER["response"] == 4
+ assert MIDDLEWARE_INVOKE_COUNTER["request"] == 4
+
+
+def test_bp_group_list_operations(app: Sanic):
+ blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1")
+ blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2")
+
+ @blueprint_1.route("/")
+ def blueprint_1_default_route(request):
+ return text("BP1_OK")
+
+ @blueprint_2.route("/")
+ def blueprint_2_default_route(request):
+ return text("BP2_OK")
+
+ blueprint_group_1 = Blueprint.group(
+ blueprint_1, blueprint_2, url_prefix="/bp"
+ )
+
+ blueprint_3 = Blueprint("blueprint_2", url_prefix="/bp3")
+
+ @blueprint_3.route("/second")
+ def blueprint_3_second_route(request):
+ return text("BP3_OK")
+
+ assert len(blueprint_group_1) == 2
+
+ blueprint_group_1.append(blueprint_3)
+ assert len(blueprint_group_1) == 3
+
+ del blueprint_group_1[2]
+ assert len(blueprint_group_1) == 2
+
+ blueprint_group_1[1] = blueprint_3
+ assert len(blueprint_group_1) == 2
+
+ assert blueprint_group_1.url_prefix == "/bp"