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"