Version prefix (#2137)

* Add version prefixing

* Versioning tests

* Testing BP group properties
This commit is contained in:
Adam Hopkins 2021-05-19 13:32:40 +03:00 committed by GitHub
parent 28ba8e53df
commit 3a6fac7d59
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 268 additions and 4 deletions

View File

@ -58,13 +58,20 @@ class BlueprintGroup(MutableSequence):
app.blueprint(bpg)
"""
__slots__ = ("_blueprints", "_url_prefix", "_version", "_strict_slashes")
__slots__ = (
"_blueprints",
"_url_prefix",
"_version",
"_strict_slashes",
"_version_prefix",
)
def __init__(
self,
url_prefix: Optional[str] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
):
"""
Create a new Blueprint Group
@ -77,6 +84,7 @@ class BlueprintGroup(MutableSequence):
self._blueprints: List[Blueprint] = []
self._url_prefix = url_prefix
self._version = version
self._version_prefix = version_prefix
self._strict_slashes = strict_slashes
@property
@ -116,6 +124,15 @@ class BlueprintGroup(MutableSequence):
"""
return self._strict_slashes
@property
def version_prefix(self) -> str:
"""
Version prefix; defaults to ``/v``
:return: str
"""
return self._version_prefix
def __iter__(self):
"""
Tun the class Blueprint Group into an Iterable item
@ -186,6 +203,9 @@ class BlueprintGroup(MutableSequence):
for _attr in ["version", "strict_slashes"]:
if getattr(bp, _attr) is None:
setattr(bp, _attr, getattr(self, _attr))
if bp.version_prefix == "/v":
bp.version_prefix = self._version_prefix
return bp
def append(self, value: "sanic.Blueprint") -> None:

View File

@ -72,6 +72,7 @@ class Blueprint(BaseSanic):
host: Optional[str] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
):
super().__init__()
@ -91,6 +92,7 @@ class Blueprint(BaseSanic):
else url_prefix
)
self.version = version
self.version_prefix = version_prefix
self.websocket_routes: List[Route] = []
def __repr__(self) -> str:
@ -143,7 +145,13 @@ class Blueprint(BaseSanic):
return super().signal(event, *args, **kwargs)
@staticmethod
def group(*blueprints, url_prefix="", version=None, strict_slashes=None):
def group(
*blueprints,
url_prefix="",
version=None,
strict_slashes=None,
version_prefix: str = "/v",
):
"""
Create a list of blueprints, optionally grouping them under a
general URL prefix.
@ -169,6 +177,7 @@ class Blueprint(BaseSanic):
url_prefix=url_prefix,
version=version,
strict_slashes=strict_slashes,
version_prefix=version_prefix,
)
for bp in chain(blueprints):
bps.append(bp)
@ -186,6 +195,8 @@ class Blueprint(BaseSanic):
self._apps.add(app)
url_prefix = options.get("url_prefix", self.url_prefix)
opt_version = options.get("version", None)
opt_version_prefix = options.get("version_prefix", self.version_prefix)
routes = []
middleware = []
@ -200,6 +211,21 @@ class Blueprint(BaseSanic):
# Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri
version_prefix = self.version_prefix
for prefix in (
future.version_prefix,
opt_version_prefix,
):
if prefix and prefix != "/v":
version_prefix = prefix
break
version = self.version
for v in (future.version, opt_version, self.version):
if v is not None:
version = v
break
strict_slashes = (
self.strict_slashes
if future.strict_slashes is None
@ -215,13 +241,14 @@ class Blueprint(BaseSanic):
future.host or self.host,
strict_slashes,
future.stream,
future.version or self.version,
version,
name,
future.ignore_body,
future.websocket,
future.subprotocols,
future.unquote,
future.static,
version_prefix,
)
route = app._apply_route(apply_route)

View File

@ -53,6 +53,7 @@ class RouteMixin:
websocket: bool = False,
unquote: bool = False,
static: bool = False,
version_prefix: str = "/v",
):
"""
Decorate a function to be registered as a route
@ -66,6 +67,8 @@ class RouteMixin:
:param name: user defined route name for url_for
:param ignore_body: whether the handler should ignore request
body (eg. GET requests)
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: tuple of routes, decorated function
"""
@ -92,6 +95,7 @@ class RouteMixin:
nonlocal subprotocols
nonlocal websocket
nonlocal static
nonlocal version_prefix
if isinstance(handler, tuple):
# if a handler fn is already wrapped in a route, the handler
@ -128,6 +132,7 @@ class RouteMixin:
subprotocols,
unquote,
static,
version_prefix,
)
self._future_routes.add(route)
@ -168,6 +173,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
stream: bool = False,
version_prefix: str = "/v",
):
"""A helper method to register class instance or
functions as a handler to the application url
@ -182,6 +188,8 @@ class RouteMixin:
:param version:
:param name: user defined route name for url_for
:param stream: boolean specifying if the handler is a stream handler
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: function or class instance
"""
# Handle HTTPMethodView differently
@ -214,6 +222,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)(handler)
return handler
@ -226,6 +235,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **GET** *HTTP* method
@ -236,6 +246,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -246,6 +258,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def post(
@ -256,6 +269,7 @@ class RouteMixin:
stream: bool = False,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
Add an API URL under the **POST** *HTTP* method
@ -266,6 +280,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -276,6 +292,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)
def put(
@ -286,6 +303,7 @@ class RouteMixin:
stream: bool = False,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
Add an API URL under the **PUT** *HTTP* method
@ -296,6 +314,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -306,6 +326,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)
def head(
@ -316,6 +337,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **HEAD** *HTTP* method
@ -334,6 +356,8 @@ class RouteMixin:
:param ignore_body: whether the handler should ignore request
body (eg. GET requests), defaults to True
:type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -344,6 +368,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def options(
@ -354,6 +379,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **OPTIONS** *HTTP* method
@ -372,6 +398,8 @@ class RouteMixin:
:param ignore_body: whether the handler should ignore request
body (eg. GET requests), defaults to True
:type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -382,6 +410,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def patch(
@ -392,6 +421,7 @@ class RouteMixin:
stream=False,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
Add an API URL under the **PATCH** *HTTP* method
@ -412,6 +442,8 @@ class RouteMixin:
:param ignore_body: whether the handler should ignore request
body (eg. GET requests), defaults to True
:type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -422,6 +454,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)
def delete(
@ -432,6 +465,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **DELETE** *HTTP* method
@ -442,6 +476,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@ -452,6 +488,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def websocket(
@ -463,6 +500,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
apply: bool = True,
version_prefix: str = "/v",
):
"""
Decorate a function to be registered as a websocket route
@ -474,6 +512,8 @@ class RouteMixin:
:param subprotocols: optional list of str with supported subprotocols
:param name: A unique name assigned to the URL so that it can
be used with :func:`url_for`
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: tuple of routes, decorated function
"""
return self.route(
@ -486,6 +526,7 @@ class RouteMixin:
apply=apply,
subprotocols=subprotocols,
websocket=True,
version_prefix=version_prefix,
)
def add_websocket_route(
@ -497,6 +538,7 @@ class RouteMixin:
subprotocols=None,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
A helper method to register a function as a websocket route.
@ -513,6 +555,8 @@ class RouteMixin:
handshake
:param name: A unique name assigned to the URL so that it can
be used with :func:`url_for`
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Objected decorated by :func:`websocket`
"""
return self.websocket(
@ -522,6 +566,7 @@ class RouteMixin:
subprotocols=subprotocols,
version=version,
name=name,
version_prefix=version_prefix,
)(handler)
def static(

View File

@ -23,6 +23,7 @@ class FutureRoute(NamedTuple):
subprotocols: Optional[List[str]]
unquote: bool
static: bool
version_prefix: str
class FutureListener(NamedTuple):

View File

@ -73,6 +73,7 @@ class Router(BaseRouter):
name: Optional[str] = None,
unquote: bool = False,
static: bool = False,
version_prefix: str = "/v",
) -> Union[Route, List[Route]]:
"""
Add a handler to the router
@ -103,7 +104,7 @@ class Router(BaseRouter):
"""
if version is not None:
version = str(version).strip("/").lstrip("v")
uri = "/".join([f"/v{version}", uri.lstrip("/")])
uri = "/".join([f"{version_prefix}{version}", uri.lstrip("/")])
params = dict(
path=uri,

View File

@ -218,3 +218,32 @@ def test_blueprint_group_insert():
assert group.blueprints[1].strict_slashes is False
assert group.blueprints[2].strict_slashes is True
assert group.blueprints[0].url_prefix == "/test"
def test_bp_group_properties():
blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1")
blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2")
group = Blueprint.group(
blueprint_1,
blueprint_2,
version=1,
version_prefix="/api/v",
url_prefix="/grouped",
strict_slashes=True,
)
assert group.version_prefix == "/api/v"
assert blueprint_1.version_prefix == "/api/v"
assert blueprint_2.version_prefix == "/api/v"
assert group.version == 1
assert blueprint_1.version == 1
assert blueprint_2.version == 1
assert group.strict_slashes
assert blueprint_1.strict_slashes
assert blueprint_2.strict_slashes
assert group.url_prefix == "/grouped"
assert blueprint_1.url_prefix == "/grouped/bp1"
assert blueprint_2.url_prefix == "/grouped/bp2"

141
tests/test_versioning.py Normal file
View File

@ -0,0 +1,141 @@
import pytest
from sanic import Blueprint, text
@pytest.fixture
def handler():
def handler(_):
return text("Done.")
return handler
def test_route(app, handler):
app.route("/", version=1)(handler)
_, response = app.test_client.get("/v1")
assert response.status == 200
def test_bp(app, handler):
bp = Blueprint(__file__, version=1)
bp.route("/")(handler)
app.blueprint(bp)
_, response = app.test_client.get("/v1")
assert response.status == 200
def test_bp_use_route(app, handler):
bp = Blueprint(__file__, version=1)
bp.route("/", version=1.1)(handler)
app.blueprint(bp)
_, response = app.test_client.get("/v1.1")
assert response.status == 200
def test_bp_group(app, handler):
bp = Blueprint(__file__)
bp.route("/")(handler)
group = Blueprint.group(bp, version=1)
app.blueprint(group)
_, response = app.test_client.get("/v1")
assert response.status == 200
def test_bp_group_use_bp(app, handler):
bp = Blueprint(__file__, version=1.1)
bp.route("/")(handler)
group = Blueprint.group(bp, version=1)
app.blueprint(group)
_, response = app.test_client.get("/v1.1")
assert response.status == 200
def test_bp_group_use_registration(app, handler):
bp = Blueprint(__file__, version=1.1)
bp.route("/")(handler)
group = Blueprint.group(bp, version=1)
app.blueprint(group, version=1.2)
_, response = app.test_client.get("/v1.2")
assert response.status == 200
def test_bp_group_use_route(app, handler):
bp = Blueprint(__file__, version=1.1)
bp.route("/", version=1.3)(handler)
group = Blueprint.group(bp, version=1)
app.blueprint(group, version=1.2)
_, response = app.test_client.get("/v1.3")
assert response.status == 200
def test_version_prefix_route(app, handler):
app.route("/", version=1, version_prefix="/api/v")(handler)
_, response = app.test_client.get("/api/v1")
assert response.status == 200
def test_version_prefix_bp(app, handler):
bp = Blueprint(__file__, version=1, version_prefix="/api/v")
bp.route("/")(handler)
app.blueprint(bp)
_, response = app.test_client.get("/api/v1")
assert response.status == 200
def test_version_prefix_bp_use_route(app, handler):
bp = Blueprint(__file__, version=1, version_prefix="/ignore/v")
bp.route("/", version=1.1, version_prefix="/api/v")(handler)
app.blueprint(bp)
_, response = app.test_client.get("/api/v1.1")
assert response.status == 200
def test_version_prefix_bp_group(app, handler):
bp = Blueprint(__file__)
bp.route("/")(handler)
group = Blueprint.group(bp, version=1, version_prefix="/api/v")
app.blueprint(group)
_, response = app.test_client.get("/api/v1")
assert response.status == 200
def test_version_prefix_bp_group_use_bp(app, handler):
bp = Blueprint(__file__, version=1.1, version_prefix="/api/v")
bp.route("/")(handler)
group = Blueprint.group(bp, version=1, version_prefix="/ignore/v")
app.blueprint(group)
_, response = app.test_client.get("/api/v1.1")
assert response.status == 200
def test_version_prefix_bp_group_use_registration(app, handler):
bp = Blueprint(__file__, version=1.1, version_prefix="/alsoignore/v")
bp.route("/")(handler)
group = Blueprint.group(bp, version=1, version_prefix="/ignore/v")
app.blueprint(group, version=1.2, version_prefix="/api/v")
_, response = app.test_client.get("/api/v1.2")
assert response.status == 200
def test_version_prefix_bp_group_use_route(app, handler):
bp = Blueprint(__file__, version=1.1, version_prefix="/alsoignore/v")
bp.route("/", version=1.3, version_prefix="/api/v")(handler)
group = Blueprint.group(bp, version=1, version_prefix="/ignore/v")
app.blueprint(group, version=1.2, version_prefix="/stillignoring/v")
_, response = app.test_client.get("/api/v1.3")
assert response.status == 200