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
This commit is contained in:
Harsha Narayana 2021-03-07 18:24:45 +05:30 committed by GitHub
parent be905e0009
commit 2c25af8cf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 315 additions and 78 deletions

View File

@ -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)

View File

@ -40,7 +40,7 @@ from sanic.exceptions import (
ServerError, ServerError,
URLBuildError, 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.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
from sanic.mixins.listeners import ListenerEvent from sanic.mixins.listeners import ListenerEvent
from sanic.models.futures import ( from sanic.models.futures import (
@ -50,6 +50,7 @@ from sanic.models.futures import (
FutureRoute, FutureRoute,
FutureStatic, FutureStatic,
) )
from sanic.models.handler_types import ListenerType, MiddlewareType
from sanic.request import Request from sanic.request import Request
from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.response import BaseHTTPResponse, HTTPResponse
from sanic.router import Router from sanic.router import Router

View File

@ -1,5 +1,7 @@
from collections.abc import MutableSequence from collections.abc import MutableSequence
from typing import List from typing import List, Optional, Union
import sanic
class BlueprintGroup(MutableSequence): class BlueprintGroup(MutableSequence):
@ -16,6 +18,11 @@ class BlueprintGroup(MutableSequence):
bp1 = Blueprint('bp1', url_prefix='/bp1') bp1 = Blueprint('bp1', url_prefix='/bp1')
bp2 = Blueprint('bp2', url_prefix='/bp2') 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') @bp1.middleware('request')
async def bp1_only_middleware(request): async def bp1_only_middleware(request):
print('applied on Blueprint : bp1 Only') print('applied on Blueprint : bp1 Only')
@ -28,6 +35,14 @@ class BlueprintGroup(MutableSequence):
async def bp2_route(request, param): async def bp2_route(request, param):
return text(param) return text(param)
@bp3.route('/')
async def bp1_route(request):
return text('bp1')
@bp4.route('/<param>')
async def bp2_route(request, param):
return text(param)
group = Blueprint.group(bp1, bp2) group = Blueprint.group(bp1, bp2)
@group.middleware('request') @group.middleware('request')
@ -36,18 +51,23 @@ class BlueprintGroup(MutableSequence):
# Register Blueprint group under the app # Register Blueprint group under the app
app.blueprint(group) 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 Create a new Blueprint Group
:param url_prefix: URL: to be prefixed before all the Blueprint Prefix :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._blueprints = []
self._url_prefix = url_prefix self._url_prefix = url_prefix
self._version = version
self._strict_slashes = strict_slashes
@property @property
def url_prefix(self) -> str: def url_prefix(self) -> str:
@ -59,7 +79,7 @@ class BlueprintGroup(MutableSequence):
return self._url_prefix return self._url_prefix
@property @property
def blueprints(self) -> List: def blueprints(self) -> List["sanic.Blueprint"]:
""" """
Retrieve a list of all the available blueprints under this group. Retrieve a list of all the available blueprints under this group.
@ -67,6 +87,25 @@ class BlueprintGroup(MutableSequence):
""" """
return self._blueprints 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): def __iter__(self):
""" """
Tun the class Blueprint Group into an Iterable item Tun the class Blueprint Group into an Iterable item
@ -121,7 +160,33 @@ class BlueprintGroup(MutableSequence):
""" """
return len(self._blueprints) 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 The Abstract class `MutableSequence` leverages this insert method to
perform the `BlueprintGroup.append` operation. perform the `BlueprintGroup.append` operation.
@ -130,7 +195,7 @@ class BlueprintGroup(MutableSequence):
:param item: New `Blueprint` object. :param item: New `Blueprint` object.
:return: None :return: None
""" """
self._blueprints.insert(index, item) self._blueprints.insert(index, self._sanitize_blueprint(item))
def middleware(self, *args, **kwargs): def middleware(self, *args, **kwargs):
""" """

View File

@ -1,11 +1,15 @@
from collections import defaultdict 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_routing.route import Route # type: ignore
from sanic.base import BaseSanic from sanic.base import BaseSanic
from sanic.blueprint_group import BlueprintGroup 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 from sanic.models.futures import FutureRoute, FutureStatic
@ -87,16 +91,18 @@ class Blueprint(BaseSanic):
return super().exception(*args, **kwargs) return super().exception(*args, **kwargs)
@staticmethod @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 Create a list of blueprints, optionally grouping them under a
general URL prefix. general URL prefix.
:param blueprints: blueprints to be registered as a group :param blueprints: blueprints to be registered as a group
:param url_prefix: URL route to be prepended to all sub-prefixes :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""" """itertools.chain() but leaves strings untouched"""
for i in nested: for i in nested:
if isinstance(i, (list, tuple)): if isinstance(i, (list, tuple)):
@ -106,11 +112,12 @@ class Blueprint(BaseSanic):
else: else:
yield i 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): 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) bps.append(bp)
return bps return bps

View File

@ -1,6 +1,4 @@
from asyncio.events import AbstractEventLoop
from traceback import format_exc from traceback import format_exc
from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
from sanic.errorpages import exception_response from sanic.errorpages import exception_response
from sanic.exceptions import ( from sanic.exceptions import (
@ -9,24 +7,7 @@ from sanic.exceptions import (
InvalidRangeType, InvalidRangeType,
) )
from sanic.log import logger from sanic.log import logger
from sanic.request import Request from sanic.response import text
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]]
class ErrorHandler: class ErrorHandler:

View File

@ -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( class FutureRoute(NamedTuple):
"FutureRoute", handler: str
[ uri: str
"handler", methods: Optional[Iterable[str]]
"uri", host: str
"methods", strict_slashes: bool
"host", stream: bool
"strict_slashes", version: Optional[int]
"stream", name: str
"version", ignore_body: bool
"name", websocket: bool
"ignore_body", subprotocols: Optional[List[str]]
"websocket", unquote: bool
"subprotocols", static: bool
"unquote",
"static",
], class FutureListener(NamedTuple):
) listener: ListenerType
FutureListener = namedtuple("FutureListener", ["listener", "event"]) event: str
FutureMiddleware = namedtuple("FutureMiddleware", ["middleware", "attach_to"])
FutureException = namedtuple("FutureException", ["handler", "exceptions"])
FutureStatic = namedtuple( class FutureMiddleware(NamedTuple):
"FutureStatic", middleware: MiddlewareType
[ attach_to: str
"uri",
"file_or_directory",
"pattern", class FutureException(NamedTuple):
"use_modified_since", handler: ErrorMiddlewareType
"use_content_range", exceptions: List[BaseException]
"stream_large_files",
"name",
"host", class FutureStatic(NamedTuple):
"strict_slashes", uri: str
"content_type", 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]

View File

@ -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]]

View File

@ -10,7 +10,7 @@ from sanic_routing.route import Route # type: ignore
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS
from sanic.exceptions import MethodNotSupported, NotFound, SanicException from sanic.exceptions import MethodNotSupported, NotFound, SanicException
from sanic.handlers import RouteHandler from sanic.models.handler_types import RouteHandler
from sanic.request import Request from sanic.request import Request

View File

@ -12,7 +12,7 @@ from typing import (
Union, Union,
) )
from sanic.handlers import ListenerType from sanic.models.handler_types import ListenerType
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@ -176,9 +176,7 @@ class WebSocketConnection:
await self._send( await self._send(
{ {
"type": "websocket.accept", "type": "websocket.accept",
"subprotocol": ",".join( "subprotocol": ",".join(list(self.subprotocols)),
list(self.subprotocols)
),
} }
) )

View File

@ -9,7 +9,6 @@ from typing import Tuple
import pytest import pytest
from sanic_routing.exceptions import RouteExists from sanic_routing.exceptions import RouteExists
from sanic_testing import TestManager
from sanic import Sanic from sanic import Sanic
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS

View File

@ -2,6 +2,7 @@ from pytest import raises
from sanic.app import Sanic from sanic.app import Sanic
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.blueprint_group import BlueprintGroup
from sanic.request import Request from sanic.request import Request
from sanic.response import HTTPResponse, text from sanic.response import HTTPResponse, text
@ -200,3 +201,20 @@ def test_bp_group_as_nested_group():
Blueprint.group(blueprint_1, blueprint_2) Blueprint.group(blueprint_1, blueprint_2)
) )
assert len(blueprint_group_1) == 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"

View File

@ -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
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