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

View File

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

View File

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

View File

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

View File

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

View File

@ -218,3 +218,32 @@ def test_blueprint_group_insert():
assert group.blueprints[1].strict_slashes is False assert group.blueprints[1].strict_slashes is False
assert group.blueprints[2].strict_slashes is True assert group.blueprints[2].strict_slashes is True
assert group.blueprints[0].url_prefix == "/test" 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