diff --git a/docs/sanic/versioning.md b/docs/sanic/versioning.md new file mode 100644 index 00000000..85cbd278 --- /dev/null +++ b/docs/sanic/versioning.md @@ -0,0 +1,50 @@ +# Versioning + +You can pass the `version` keyword to the route decorators, or to a blueprint initializer. It will result in the `v{version}` url prefix where `{version}` is the version number. + +## Per route + +You can pass a version number to the routes directly. + +```python +from sanic import response + + +@app.route('/text', verion=1) +def handle_request(request): + return response.text('Hello world! Version 1') + +@app.route('/text', verion=2) +def handle_request(request): + return response.text('Hello world! Version 2') + +app.run(port=80) +``` + +Then with curl: + +```bash +curl localhost/v1/text +curl localhost/v2/text +``` + +## Global blueprint version + +You can also pass a version number to the blueprint, which will apply to all routes. + +```python +from sanic import response +from sanic.blueprints import Blueprint + +bp = Blueprint('test', version=1) + +@bp.route('/html') +def handle_request(request): + return response.html('
Hello world!
') +``` + +Then with curl: + +```bash +curl localhost/v1/html +``` diff --git a/sanic/app.py b/sanic/app.py index 5b071200..f1e8be7e 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -113,7 +113,7 @@ class Sanic: # Decorator def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False, stream=False): + strict_slashes=False, stream=False, version=None): """Decorate a function to be registered as a route :param uri: path of the URL @@ -136,42 +136,49 @@ class Sanic: if stream: handler.is_stream = stream self.router.add(uri=uri, methods=methods, handler=handler, - host=host, strict_slashes=strict_slashes) + host=host, strict_slashes=strict_slashes, + version=version) return handler return response # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=False): + def get(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"GET"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def post(self, uri, host=None, strict_slashes=False, stream=False): + def post(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=frozenset({"POST"}), host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def put(self, uri, host=None, strict_slashes=False, stream=False): + def put(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=frozenset({"PUT"}), host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def head(self, uri, host=None, strict_slashes=False): + def head(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"HEAD"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def options(self, uri, host=None, strict_slashes=False): + def options(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"OPTIONS"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def patch(self, uri, host=None, strict_slashes=False, stream=False): + def patch(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=frozenset({"PATCH"}), host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def delete(self, uri, host=None, strict_slashes=False): + def delete(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"DELETE"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False): + strict_slashes=False, version=None): """A helper method to register class instance or functions as a handler to the application url routes. @@ -204,7 +211,8 @@ class Sanic: break self.route(uri=uri, methods=methods, host=host, - strict_slashes=strict_slashes, stream=stream)(handler) + strict_slashes=strict_slashes, stream=stream, + version=version)(handler) return handler # Decorator diff --git a/sanic/blueprints.py b/sanic/blueprints.py index b3866cbd..0e97903b 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -4,8 +4,8 @@ from sanic.constants import HTTP_METHODS from sanic.views import CompositionView FutureRoute = namedtuple('Route', - ['handler', 'uri', 'methods', - 'host', 'strict_slashes', 'stream']) + ['handler', 'uri', 'methods', 'host', + 'strict_slashes', 'stream', 'version']) FutureListener = namedtuple('Listener', ['handler', 'uri', 'methods', 'host']) FutureMiddleware = namedtuple('Route', ['middleware', 'args', 'kwargs']) FutureException = namedtuple('Route', ['handler', 'args', 'kwargs']) @@ -14,7 +14,7 @@ FutureStatic = namedtuple('Route', class Blueprint: - def __init__(self, name, url_prefix=None, host=None): + def __init__(self, name, url_prefix=None, host=None, version=None): """Create a new blueprint :param name: unique name of the blueprint @@ -30,6 +30,7 @@ class Blueprint: self.listeners = defaultdict(list) self.middlewares = [] self.statics = [] + self.version = version def register(self, app, options): """Register the blueprint to the sanic app.""" @@ -43,12 +44,16 @@ class Blueprint: future.handler.__blueprintname__ = self.name # Prepend the blueprint URI prefix if available uri = url_prefix + future.uri if url_prefix else future.uri + + version = future.version or self.version + app.route( uri=uri[1:] if uri.startswith('//') else uri, methods=future.methods, host=future.host or self.host, strict_slashes=future.strict_slashes, - stream=future.stream + stream=future.stream, + version=version )(future.handler) for future in self.websocket_routes: @@ -89,7 +94,7 @@ class Blueprint: app.listener(event)(listener) def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False, stream=False): + strict_slashes=False, stream=False, version=None): """Create a blueprint route from a decorated function. :param uri: endpoint at which the route will be accessible. @@ -97,13 +102,13 @@ class Blueprint: """ def decorator(handler): route = FutureRoute( - handler, uri, methods, host, strict_slashes, stream) + handler, uri, methods, host, strict_slashes, stream, version) self.routes.append(route) return handler return decorator def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False): + strict_slashes=False, version=None): """Create a blueprint route from a function. :param handler: function for handling uri requests. Accepts function, @@ -125,21 +130,22 @@ class Blueprint: methods = handler.handlers.keys() self.route(uri=uri, methods=methods, host=host, - strict_slashes=strict_slashes)(handler) + strict_slashes=strict_slashes, version=version)(handler) return handler - def websocket(self, uri, host=None, strict_slashes=False): + def websocket(self, uri, host=None, strict_slashes=False, version=None): """Create a blueprint websocket route from a decorated function. :param uri: endpoint at which the route will be accessible. """ def decorator(handler): - route = FutureRoute(handler, uri, [], host, strict_slashes, False) + route = FutureRoute(handler, uri, [], host, strict_slashes, + False, version) self.websocket_routes.append(route) return handler return decorator - def add_websocket_route(self, handler, uri, host=None): + def add_websocket_route(self, handler, uri, host=None, version=None): """Create a blueprint websocket route from a function. :param handler: function for handling uri requests. Accepts function, @@ -147,7 +153,7 @@ class Blueprint: :param uri: endpoint at which the route will be accessible. :return: function or class instance """ - self.websocket(uri=uri, host=host)(handler) + self.websocket(uri=uri, host=host, version=version)(handler) return handler def listener(self, event): @@ -193,30 +199,36 @@ class Blueprint: self.statics.append(static) # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=False): + def get(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["GET"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def post(self, uri, host=None, strict_slashes=False, stream=False): + def post(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=["POST"], host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def put(self, uri, host=None, strict_slashes=False, stream=False): + def put(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=["PUT"], host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def head(self, uri, host=None, strict_slashes=False): + def head(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["HEAD"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def options(self, uri, host=None, strict_slashes=False): + def options(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["OPTIONS"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def patch(self, uri, host=None, strict_slashes=False, stream=False): + def patch(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=["PATCH"], host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def delete(self, uri, host=None, strict_slashes=False): + def delete(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["DELETE"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) diff --git a/sanic/router.py b/sanic/router.py index ce491881..efc48f37 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -98,7 +98,8 @@ class Router: return name, _type, pattern - def add(self, uri, methods, handler, host=None, strict_slashes=False): + def add(self, uri, methods, handler, host=None, strict_slashes=False, + version=None): """Add a handler to the route list :param uri: path to match @@ -107,8 +108,15 @@ class Router: :param handler: request handler function. When executed, it should provide a response object. :param strict_slashes: strict to trailing slash + :param version: current version of the route or blueprint. See + docs for further details. :return: Nothing """ + if version is not None: + if uri.startswith('/'): + uri = "/".join(["/v{}".format(str(version)), uri[1:]]) + else: + uri = "/".join(["/v{}".format(str(version)), uri]) # add regular version self._add(uri, methods, handler, host) diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 9ab387be..5cb356c2 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,16 +1,42 @@ import asyncio import inspect +import pytest from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text from sanic.exceptions import NotFound, ServerError, InvalidUsage +from sanic.constants import HTTP_METHODS # ------------------------------------------------------------ # # GET # ------------------------------------------------------------ # +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_routes_get(method): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_text') + + method = method.lower() + + func = getattr(bp, method) + if callable(func): + @func('/{}'.format(method), version=1) + def handler(request): + return text('OK') + else: + print(func) + raise + + app.blueprint(bp) + + client_method = getattr(app.test_client, method) + + request, response = client_method('/v1/{}'.format(method)) + assert response.status == 200 + + def test_bp(): app = Sanic('test_text') bp = Blueprint('test_text') diff --git a/tests/test_routes.py b/tests/test_routes.py index 4afb4a9c..04a682a0 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -4,12 +4,33 @@ import pytest from sanic import Sanic from sanic.response import text from sanic.router import RouteExists, RouteDoesNotExist +from sanic.constants import HTTP_METHODS # ------------------------------------------------------------ # # UTF-8 # ------------------------------------------------------------ # +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_routes_get(method): + app = Sanic('test_shorhand_routes_get') + + method = method.lower() + + func = getattr(app, method) + if callable(func): + @func('/{}'.format(method), version=1) + def handler(request): + return text('OK') + else: + print(func) + raise + + client_method = getattr(app.test_client, method) + + request, response = client_method('/v1/{}'.format(method)) + assert response.status== 200 + def test_shorthand_routes_get(): app = Sanic('test_shorhand_routes_get')