From 2c25af8cf5d22ab34c6d8da2b10d9c98176c0ad4 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sun, 7 Mar 2021 18:24:45 +0530 Subject: [PATCH] GIT-2045: enable versioning and strict slash on BlueprintGroup (#2047) * GIT-2045: enable versioning and strict slash on BlueprintGroup * GIT-2045: convert named tuple into typed format + unit tests * GIT-2045: add example code for versioned bpg * GIT-2045: None value for strict slashes check * GIT-2045: refactor handler types and add benchmark for urlparse * GIT-2045: reduce urlparse benchmark iterations * GIT-2045: add unit test and url merge behavior * GIT-2045: cleanup example code and remove print * GIT-2045: add test for slash duplication avoidence * GIT-2045: fix issue with tailing / getting appended * GIT-2045: use Optional instead of Union for Typing * GIT-2045: use string for version arg * GIT-2045: combine optional with union --- examples/versioned_blueprint_group.py | 35 ++++++++++ sanic/app.py | 3 +- sanic/blueprint_group.py | 77 ++++++++++++++++++++-- sanic/blueprints.py | 23 ++++--- sanic/handlers.py | 21 +----- sanic/models/futures.py | 87 +++++++++++++----------- sanic/models/handler_types.py | 25 +++++++ sanic/router.py | 2 +- sanic/server.py | 2 +- sanic/websocket.py | 4 +- tests/conftest.py | 1 - tests/test_blueprint_group.py | 18 +++++ tests/test_blueprints.py | 95 +++++++++++++++++++++++++++ 13 files changed, 315 insertions(+), 78 deletions(-) create mode 100644 examples/versioned_blueprint_group.py create mode 100644 sanic/models/handler_types.py diff --git a/examples/versioned_blueprint_group.py b/examples/versioned_blueprint_group.py new file mode 100644 index 00000000..77360f5d --- /dev/null +++ b/examples/versioned_blueprint_group.py @@ -0,0 +1,35 @@ +from sanic import Sanic +from sanic.blueprints import Blueprint +from sanic.response import json + + +app = Sanic(name="blue-print-group-version-example") + +bp1 = Blueprint(name="ultron", url_prefix="/ultron") +bp2 = Blueprint(name="vision", url_prefix="/vision", strict_slashes=None) + +bpg = Blueprint.group([bp1, bp2], url_prefix="/sentient/robot", version=1, strict_slashes=True) + + +@bp1.get("/name") +async def bp1_name(request): + """This will expose an Endpoint GET /v1/sentient/robot/ultron/name""" + return json({"name": "Ultron"}) + + +@bp2.get("/name") +async def bp2_name(request): + """This will expose an Endpoint GET /v1/sentient/robot/vision/name""" + return json({"name": "vision"}) + + +@bp2.get("/name", version=2) +async def bp2_revised_name(request): + """This will expose an Endpoint GET /v2/sentient/robot/vision/name""" + return json({"name": "new vision"}) + + +app.blueprint(bpg) + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/sanic/app.py b/sanic/app.py index c3c2b9ea..23cc68ea 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -40,7 +40,7 @@ from sanic.exceptions import ( ServerError, URLBuildError, ) -from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType +from sanic.handlers import ErrorHandler from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger from sanic.mixins.listeners import ListenerEvent from sanic.models.futures import ( @@ -50,6 +50,7 @@ from sanic.models.futures import ( FutureRoute, FutureStatic, ) +from sanic.models.handler_types import ListenerType, MiddlewareType from sanic.request import Request from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.router import Router diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index 3a1d8484..b6b1029e 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -1,5 +1,7 @@ from collections.abc import MutableSequence -from typing import List +from typing import List, Optional, Union + +import sanic class BlueprintGroup(MutableSequence): @@ -16,6 +18,11 @@ class BlueprintGroup(MutableSequence): bp1 = Blueprint('bp1', url_prefix='/bp1') bp2 = Blueprint('bp2', url_prefix='/bp2') + bp3 = Blueprint('bp3', url_prefix='/bp4') + bp3 = Blueprint('bp3', url_prefix='/bp4') + + bpg = BlueprintGroup(bp3, bp4, url_prefix="/api", version="v1") + @bp1.middleware('request') async def bp1_only_middleware(request): print('applied on Blueprint : bp1 Only') @@ -28,6 +35,14 @@ class BlueprintGroup(MutableSequence): async def bp2_route(request, param): return text(param) + @bp3.route('/') + async def bp1_route(request): + return text('bp1') + + @bp4.route('/') + async def bp2_route(request, param): + return text(param) + group = Blueprint.group(bp1, bp2) @group.middleware('request') @@ -36,18 +51,23 @@ class BlueprintGroup(MutableSequence): # Register Blueprint group under the app app.blueprint(group) + app.blueprint(bpg) """ - __slots__ = ("_blueprints", "_url_prefix") + __slots__ = ("_blueprints", "_url_prefix", "_version", "_strict_slashes") - def __init__(self, url_prefix=None): + def __init__(self, url_prefix=None, version=None, strict_slashes=None): """ Create a new Blueprint Group :param url_prefix: URL: to be prefixed before all the Blueprint Prefix + :param version: API Version for the blueprint group. This will be inherited by each of the Blueprint + :param strict_slashes: URL Strict slash behavior indicator """ self._blueprints = [] self._url_prefix = url_prefix + self._version = version + self._strict_slashes = strict_slashes @property def url_prefix(self) -> str: @@ -59,7 +79,7 @@ class BlueprintGroup(MutableSequence): return self._url_prefix @property - def blueprints(self) -> List: + def blueprints(self) -> List["sanic.Blueprint"]: """ Retrieve a list of all the available blueprints under this group. @@ -67,6 +87,25 @@ class BlueprintGroup(MutableSequence): """ return self._blueprints + @property + def version(self) -> Optional[Union[str, int, float]]: + """ + API Version for the Blueprint Group. This will be applied only in case if the Blueprint doesn't already have + a version specified + + :return: Version information + """ + return self._version + + @property + def strict_slashes(self) -> Optional[bool]: + """ + URL Slash termination behavior configuration + + :return: bool + """ + return self._strict_slashes + def __iter__(self): """ Tun the class Blueprint Group into an Iterable item @@ -121,7 +160,33 @@ class BlueprintGroup(MutableSequence): """ return len(self._blueprints) - def insert(self, index: int, item: object) -> None: + def _sanitize_blueprint(self, bp: "sanic.Blueprint") -> "sanic.Blueprint": + """ + Sanitize the Blueprint Entity to override the Version and strict slash behaviors as required. + + :param bp: Sanic Blueprint entity Object + :return: Modified Blueprint + """ + if self._url_prefix: + merged_prefix = "/".join( + u.strip("/") for u in [self._url_prefix, bp.url_prefix or ""] + ).rstrip("/") + bp.url_prefix = f"/{merged_prefix}" + for _attr in ["version", "strict_slashes"]: + if getattr(bp, _attr) is None: + setattr(bp, _attr, getattr(self, _attr)) + return bp + + def append(self, value: "sanic.Blueprint") -> None: + """ + The Abstract class `MutableSequence` leverages this append method to + perform the `BlueprintGroup.append` operation. + :param value: New `Blueprint` object. + :return: None + """ + self._blueprints.append(self._sanitize_blueprint(bp=value)) + + def insert(self, index: int, item: "sanic.Blueprint") -> None: """ The Abstract class `MutableSequence` leverages this insert method to perform the `BlueprintGroup.append` operation. @@ -130,7 +195,7 @@ class BlueprintGroup(MutableSequence): :param item: New `Blueprint` object. :return: None """ - self._blueprints.insert(index, item) + self._blueprints.insert(index, self._sanitize_blueprint(item)) def middleware(self, *args, **kwargs): """ diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 038dbd4b..1de4daeb 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,11 +1,15 @@ from collections import defaultdict -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Iterable from sanic_routing.route import Route # type: ignore from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup -from sanic.handlers import ListenerType, MiddlewareType, RouteHandler +from sanic.models.handler_types import ( + ListenerType, + MiddlewareType, + RouteHandler, +) from sanic.models.futures import FutureRoute, FutureStatic @@ -87,16 +91,18 @@ class Blueprint(BaseSanic): return super().exception(*args, **kwargs) @staticmethod - def group(*blueprints, url_prefix=""): + def group(*blueprints, url_prefix="", version=None, strict_slashes=None): """ Create a list of blueprints, optionally grouping them under a general URL prefix. :param blueprints: blueprints to be registered as a group :param url_prefix: URL route to be prepended to all sub-prefixes + :param version: API Version to be used for Blueprint group + :param strict_slashes: Indicate strict slash termination behavior for URL """ - def chain(nested): + def chain(nested) -> Iterable[Blueprint]: """itertools.chain() but leaves strings untouched""" for i in nested: if isinstance(i, (list, tuple)): @@ -106,11 +112,12 @@ class Blueprint(BaseSanic): else: yield i - bps = BlueprintGroup(url_prefix=url_prefix) + bps = BlueprintGroup( + url_prefix=url_prefix, + version=version, + strict_slashes=strict_slashes, + ) for bp in chain(blueprints): - if bp.url_prefix is None: - bp.url_prefix = "" - bp.url_prefix = url_prefix + bp.url_prefix bps.append(bp) return bps diff --git a/sanic/handlers.py b/sanic/handlers.py index 947e874e..58754749 100644 --- a/sanic/handlers.py +++ b/sanic/handlers.py @@ -1,6 +1,4 @@ -from asyncio.events import AbstractEventLoop from traceback import format_exc -from typing import Any, Callable, Coroutine, Optional, TypeVar, Union from sanic.errorpages import exception_response from sanic.exceptions import ( @@ -9,24 +7,7 @@ from sanic.exceptions import ( InvalidRangeType, ) from sanic.log import logger -from sanic.request import Request -from sanic.response import BaseHTTPResponse, HTTPResponse, text - - -Sanic = TypeVar("Sanic") - -MiddlewareResponse = Union[ - Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]] -] -RequestMiddlewareType = Callable[[Request], MiddlewareResponse] -ResponseMiddlewareType = Callable[ - [Request, BaseHTTPResponse], MiddlewareResponse -] -MiddlewareType = Union[RequestMiddlewareType, ResponseMiddlewareType] -ListenerType = Callable[ - [Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]] -] -RouteHandler = Callable[..., Coroutine[Any, Any, HTTPResponse]] +from sanic.response import text class ErrorHandler: diff --git a/sanic/models/futures.py b/sanic/models/futures.py index 4ffa13bb..083b9815 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -1,39 +1,52 @@ -from collections import namedtuple +from pathlib import PurePath +from typing import NamedTuple, List, Union, Iterable, Optional + +from sanic.models.handler_types import ( + ListenerType, + MiddlewareType, + ErrorMiddlewareType, +) -FutureRoute = namedtuple( - "FutureRoute", - [ - "handler", - "uri", - "methods", - "host", - "strict_slashes", - "stream", - "version", - "name", - "ignore_body", - "websocket", - "subprotocols", - "unquote", - "static", - ], -) -FutureListener = namedtuple("FutureListener", ["listener", "event"]) -FutureMiddleware = namedtuple("FutureMiddleware", ["middleware", "attach_to"]) -FutureException = namedtuple("FutureException", ["handler", "exceptions"]) -FutureStatic = namedtuple( - "FutureStatic", - [ - "uri", - "file_or_directory", - "pattern", - "use_modified_since", - "use_content_range", - "stream_large_files", - "name", - "host", - "strict_slashes", - "content_type", - ], -) +class FutureRoute(NamedTuple): + handler: str + uri: str + methods: Optional[Iterable[str]] + host: str + strict_slashes: bool + stream: bool + version: Optional[int] + name: str + ignore_body: bool + websocket: bool + subprotocols: Optional[List[str]] + unquote: bool + static: bool + + +class FutureListener(NamedTuple): + listener: ListenerType + event: str + + +class FutureMiddleware(NamedTuple): + middleware: MiddlewareType + attach_to: str + + +class FutureException(NamedTuple): + handler: ErrorMiddlewareType + exceptions: List[BaseException] + + +class FutureStatic(NamedTuple): + uri: str + file_or_directory: Union[str, bytes, PurePath] + pattern: str + use_modified_since: bool + use_content_range: bool + stream_large_files: bool + name: str + host: Optional[str] + strict_slashes: Optional[bool] + content_type: Optional[bool] diff --git a/sanic/models/handler_types.py b/sanic/models/handler_types.py new file mode 100644 index 00000000..e052e3dd --- /dev/null +++ b/sanic/models/handler_types.py @@ -0,0 +1,25 @@ +from asyncio.events import AbstractEventLoop +from typing import Any, Callable, Coroutine, Optional, TypeVar, Union + +from sanic.request import Request +from sanic.response import BaseHTTPResponse, HTTPResponse + + +Sanic = TypeVar("Sanic") + + +MiddlewareResponse = Union[ + Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]] +] +RequestMiddlewareType = Callable[[Request], MiddlewareResponse] +ResponseMiddlewareType = Callable[ + [Request, BaseHTTPResponse], MiddlewareResponse +] +ErrorMiddlewareType = Callable[ + [Request, BaseException], Optional[Coroutine[Any, Any, None]] +] +MiddlewareType = Union[RequestMiddlewareType, ResponseMiddlewareType] +ListenerType = Callable[ + [Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]] +] +RouteHandler = Callable[..., Coroutine[Any, Any, HTTPResponse]] diff --git a/sanic/router.py b/sanic/router.py index df14929f..d0bad197 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -10,7 +10,7 @@ from sanic_routing.route import Route # type: ignore from sanic.constants import HTTP_METHODS from sanic.exceptions import MethodNotSupported, NotFound, SanicException -from sanic.handlers import RouteHandler +from sanic.models.handler_types import RouteHandler from sanic.request import Request diff --git a/sanic/server.py b/sanic/server.py index 0e929226..3c7b1d29 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -12,7 +12,7 @@ from typing import ( Union, ) -from sanic.handlers import ListenerType +from sanic.models.handler_types import ListenerType if TYPE_CHECKING: diff --git a/sanic/websocket.py b/sanic/websocket.py index 29d1dbab..6b325e26 100644 --- a/sanic/websocket.py +++ b/sanic/websocket.py @@ -176,9 +176,7 @@ class WebSocketConnection: await self._send( { "type": "websocket.accept", - "subprotocol": ",".join( - list(self.subprotocols) - ), + "subprotocol": ",".join(list(self.subprotocols)), } ) diff --git a/tests/conftest.py b/tests/conftest.py index a305b9fe..70d97573 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ from typing import Tuple import pytest from sanic_routing.exceptions import RouteExists -from sanic_testing import TestManager from sanic import Sanic from sanic.constants import HTTP_METHODS diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index df2bda88..168c84fa 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -2,6 +2,7 @@ from pytest import raises from sanic.app import Sanic from sanic.blueprints import Blueprint +from sanic.blueprint_group import BlueprintGroup from sanic.request import Request from sanic.response import HTTPResponse, text @@ -200,3 +201,20 @@ def test_bp_group_as_nested_group(): Blueprint.group(blueprint_1, blueprint_2) ) assert len(blueprint_group_1) == 2 + + +def test_blueprint_group_insert(): + blueprint_1 = Blueprint( + "blueprint_1", url_prefix="/bp1", strict_slashes=True, version=1 + ) + blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2") + blueprint_3 = Blueprint("blueprint_3", url_prefix=None) + group = BlueprintGroup( + url_prefix="/test", version=1.3, strict_slashes=False + ) + group.insert(0, blueprint_1) + group.insert(0, blueprint_2) + group.insert(0, blueprint_3) + assert group.blueprints[1].strict_slashes is False + assert group.blueprints[2].strict_slashes is True + assert group.blueprints[0].url_prefix == "/test" diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 25e2621a..51290ad4 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -893,3 +893,98 @@ def test_strict_slashes_behavior_adoption(): assert app.test_client.get("/f1")[1].status == 200 assert app.test_client.get("/f1/")[1].status == 200 + + +def test_blueprint_group_versioning(): + app = Sanic(name="blueprint-group-test") + + bp1 = Blueprint(name="bp1", url_prefix="/bp1") + bp2 = Blueprint(name="bp2", url_prefix="/bp2", version=2) + + bp3 = Blueprint(name="bp3", url_prefix="/bp3") + + @bp3.get("/r1") + async def bp3_r1(request): + return json({"from": "bp3/r1"}) + + @bp1.get("/pre-group") + async def pre_group(request): + return json({"from": "bp1/pre-group"}) + + group = Blueprint.group([bp1, bp2], url_prefix="/group1", version=1) + + group2 = Blueprint.group([bp3]) + + @bp1.get("/r1") + async def r1(request): + return json({"from": "bp1/r1"}) + + @bp2.get("/r2") + async def r2(request): + return json({"from": "bp2/r2"}) + + @bp2.get("/r3", version=3) + async def r3(request): + return json({"from": "bp2/r3"}) + + app.blueprint([group, group2]) + + assert app.test_client.get("/v1/group1/bp1/r1/")[1].status == 200 + assert app.test_client.get("/v2/group1/bp2/r2")[1].status == 200 + assert app.test_client.get("/v1/group1/bp1/pre-group")[1].status == 200 + assert app.test_client.get("/v3/group1/bp2/r3")[1].status == 200 + assert app.test_client.get("/bp3/r1")[1].status == 200 + + assert group.version == 1 + assert group2.strict_slashes is None + + +def test_blueprint_group_strict_slashes(): + app = Sanic(name="blueprint-group-test") + bp1 = Blueprint(name="bp1", url_prefix=None, strict_slashes=False) + + bp2 = Blueprint( + name="bp2", version=3, url_prefix="/bp2", strict_slashes=None + ) + + bp3 = Blueprint( + name="bp3", version=None, url_prefix="/bp3/", strict_slashes=None + ) + + @bp1.get("/r1") + async def bp1_r1(request): + return json({"from": "bp1/r1"}) + + @bp2.get("/r1") + async def bp2_r1(request): + return json({"from": "bp2/r1"}) + + @bp2.get("/r2/") + async def bp2_r2(request): + return json({"from": "bp2/r2"}) + + @bp3.get("/r1") + async def bp3_r1(request): + return json({"from": "bp3/r1"}) + + group = Blueprint.group( + [bp1, bp2], + url_prefix="/slash-check/", + version=1.3, + strict_slashes=True, + ) + + group2 = Blueprint.group( + [bp3], url_prefix="/other-prefix/", version="v2", strict_slashes=False + ) + + app.blueprint(group) + app.blueprint(group2) + + assert app.test_client.get("/v1.3/slash-check/r1")[1].status == 200 + assert app.test_client.get("/v1.3/slash-check/r1/")[1].status == 200 + assert app.test_client.get("/v3/slash-check/bp2/r1")[1].status == 200 + assert app.test_client.get("/v3/slash-check/bp2/r1/")[1].status == 404 + assert app.test_client.get("/v3/slash-check/bp2/r2")[1].status == 404 + assert app.test_client.get("/v3/slash-check/bp2/r2/")[1].status == 200 + assert app.test_client.get("/v2/other-prefix/bp3/r1")[1].status == 200