diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index 27716d8f..7dd729a9 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -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: diff --git a/sanic/blueprints.py b/sanic/blueprints.py index aec661b2..c17a0637 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -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) diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 0b5df17e..30f5cd3f 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -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( diff --git a/sanic/models/futures.py b/sanic/models/futures.py index f6371b4d..2350bedb 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -23,6 +23,7 @@ class FutureRoute(NamedTuple): subprotocols: Optional[List[str]] unquote: bool static: bool + version_prefix: str class FutureListener(NamedTuple): diff --git a/sanic/router.py b/sanic/router.py index 47dd69f4..e3343037 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -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, diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index 7c4bbf90..893cc805 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -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" diff --git a/tests/test_versioning.py b/tests/test_versioning.py new file mode 100644 index 00000000..dbada37f --- /dev/null +++ b/tests/test_versioning.py @@ -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