diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md index f1882684..b420a523 100644 --- a/docs/sanic/routing.md +++ b/docs/sanic/routing.md @@ -239,3 +239,31 @@ def handler(request): app.blueprint(bp) ``` + +## User defined route name + +You can pass `name` to change the route name to avoid using the default name (`handler.__name__`). + +```python + +app = Sanic('test_named_route') + +@app.get('/get', name='get_handler') +def handler(request): + return text('OK') + +# then you need use `app.url_for('get_handler')` +# instead of # `app.url_for('handler')` + +# It also works for blueprints +bp = Blueprint('test_named_bp') + +@bp.get('/bp/get', name='get_handler') +def handler(request): + return text('OK') + +app.blueprint(bp) + +# then you need use `app.url_for('test_named_bp.get_handler')` +# instead of `app.url_for('test_named_bp.handler')` +``` diff --git a/sanic/app.py b/sanic/app.py index 53a6a8f6..20c02a5c 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -112,7 +112,7 @@ class Sanic: # Decorator def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=None, stream=False, version=None): + strict_slashes=None, stream=False, version=None, name=None): """Decorate a function to be registered as a route :param uri: path of the URL @@ -120,6 +120,8 @@ class Sanic: :param host: :param strict_slashes: :param stream: + :param version: + :param name: user defined route name for url_for :return: decorated function """ @@ -139,48 +141,56 @@ class Sanic: handler.is_stream = stream self.router.add(uri=uri, methods=methods, handler=handler, host=host, strict_slashes=strict_slashes, - version=version) + version=version, name=name) return handler return response # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=None, version=None): + def get(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=frozenset({"GET"}), host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) def post(self, uri, host=None, strict_slashes=None, stream=False, - version=None): + version=None, name=None): return self.route(uri, methods=frozenset({"POST"}), host=host, strict_slashes=strict_slashes, stream=stream, - version=version) + version=version, name=name) def put(self, uri, host=None, strict_slashes=None, stream=False, - version=None): + version=None, name=None): return self.route(uri, methods=frozenset({"PUT"}), host=host, strict_slashes=strict_slashes, stream=stream, - version=version) + version=version, name=name) - def head(self, uri, host=None, strict_slashes=None, version=None): + def head(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=frozenset({"HEAD"}), host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) - def options(self, uri, host=None, strict_slashes=None, version=None): + def options(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=frozenset({"OPTIONS"}), host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) def patch(self, uri, host=None, strict_slashes=None, stream=False, - version=None): + version=None, name=None): return self.route(uri, methods=frozenset({"PATCH"}), host=host, strict_slashes=strict_slashes, stream=stream, - version=version) + version=version, name=name) - def delete(self, uri, host=None, strict_slashes=None, version=None): + def delete(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=frozenset({"DELETE"}), host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=None, version=None): + strict_slashes=None, version=None, name=None): """A helper method to register class instance or functions as a handler to the application url routes. @@ -190,6 +200,9 @@ class Sanic: :param methods: list or tuple of methods allowed, these are overridden if using a HTTPMethodView :param host: + :param strict_slashes: + :param version: + :param name: user defined route name for url_for :return: function or class instance """ stream = False @@ -217,12 +230,12 @@ class Sanic: self.route(uri=uri, methods=methods, host=host, strict_slashes=strict_slashes, stream=stream, - version=version)(handler) + version=version, name=name)(handler) return handler # Decorator def websocket(self, uri, host=None, strict_slashes=None, - subprotocols=None): + subprotocols=None, name=None): """Decorate a function to be registered as a websocket route :param uri: path of the URL :param subprotocols: optional list of strings with the supported @@ -265,19 +278,19 @@ class Sanic: self.router.add(uri=uri, handler=websocket_handler, methods=frozenset({'GET'}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, name=name) return handler return response def add_websocket_route(self, handler, uri, host=None, - strict_slashes=None): + strict_slashes=None, name=None): """A helper method to register a function as a websocket route.""" if strict_slashes is None: strict_slashes = self.strict_slashes - return self.websocket(uri, host=host, - strict_slashes=strict_slashes)(handler) + return self.websocket(uri, host=host, strict_slashes=strict_slashes, + name=name)(handler) def enable_websocket(self, enable=True): """Enable or disable the support for websocket. @@ -400,9 +413,8 @@ class Sanic: uri, route = self.router.find_route_by_view_name(view_name) if not uri or not route: - raise URLBuildError( - 'Endpoint with name `{}` was not found'.format( - view_name)) + raise URLBuildError('Endpoint with name `{}` was not found'.format( + view_name)) if uri != '/' and uri.endswith('/'): uri = uri[:-1] diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 235fe909..548aa7ca 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -5,7 +5,7 @@ from sanic.views import CompositionView FutureRoute = namedtuple('Route', ['handler', 'uri', 'methods', 'host', - 'strict_slashes', 'stream', 'version']) + 'strict_slashes', 'stream', 'version', 'name']) FutureListener = namedtuple('Listener', ['handler', 'uri', 'methods', 'host']) FutureMiddleware = namedtuple('Route', ['middleware', 'args', 'kwargs']) FutureException = namedtuple('Route', ['handler', 'args', 'kwargs']) @@ -53,14 +53,14 @@ class Blueprint: 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, - version=version - )(future.handler) + 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, + version=version, + name=future.name, + )(future.handler) for future in self.websocket_routes: # attach the blueprint name to the handler so that it can be @@ -68,11 +68,11 @@ 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 - app.websocket( - uri=uri, - host=future.host or self.host, - strict_slashes=future.strict_slashes - )(future.handler) + app.websocket(uri=uri, + host=future.host or self.host, + strict_slashes=future.strict_slashes, + name=future.name, + )(future.handler) # Middleware for future in self.middlewares: @@ -100,7 +100,7 @@ class Blueprint: app.listener(event)(listener) def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=None, stream=False, version=None): + strict_slashes=None, stream=False, version=None, name=None): """Create a blueprint route from a decorated function. :param uri: endpoint at which the route will be accessible. @@ -111,19 +111,24 @@ class Blueprint: def decorator(handler): route = FutureRoute( - handler, uri, methods, host, strict_slashes, stream, version) + handler, uri, methods, host, strict_slashes, stream, version, + name) self.routes.append(route) return handler return decorator def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=None, version=None): + strict_slashes=None, version=None, name=None): """Create a blueprint route from a function. :param handler: function for handling uri requests. Accepts function, or class instance with a view_class method. :param uri: endpoint at which the route will be accessible. :param methods: list of acceptable HTTP methods. + :param host: + :param strict_slashes: + :param version: + :param name: user defined route name for url_for :return: function or class instance """ # Handle HTTPMethodView differently @@ -142,10 +147,12 @@ class Blueprint: methods = handler.handlers.keys() self.route(uri=uri, methods=methods, host=host, - strict_slashes=strict_slashes, version=version)(handler) + strict_slashes=strict_slashes, version=version, + name=name)(handler) return handler - def websocket(self, uri, host=None, strict_slashes=None, version=None): + def websocket(self, uri, host=None, strict_slashes=None, version=None, + name=None): """Create a blueprint websocket route from a decorated function. :param uri: endpoint at which the route will be accessible. @@ -155,12 +162,13 @@ class Blueprint: def decorator(handler): route = FutureRoute(handler, uri, [], host, strict_slashes, - False, version) + False, version, name) self.websocket_routes.append(route) return handler return decorator - def add_websocket_route(self, handler, uri, host=None, version=None): + def add_websocket_route(self, handler, uri, host=None, version=None, + name=None): """Create a blueprint websocket route from a function. :param handler: function for handling uri requests. Accepts function, @@ -168,7 +176,7 @@ class Blueprint: :param uri: endpoint at which the route will be accessible. :return: function or class instance """ - self.websocket(uri=uri, host=host, version=version)(handler) + self.websocket(uri=uri, host=host, version=version, name=name)(handler) return handler def listener(self, event): @@ -214,36 +222,44 @@ class Blueprint: self.statics.append(static) # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=None, version=None): + def get(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=["GET"], host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) def post(self, uri, host=None, strict_slashes=None, stream=False, - version=None): + version=None, name=None): return self.route(uri, methods=["POST"], host=host, strict_slashes=strict_slashes, stream=stream, - version=version) + version=version, name=name) def put(self, uri, host=None, strict_slashes=None, stream=False, - version=None): + version=None, name=None): return self.route(uri, methods=["PUT"], host=host, strict_slashes=strict_slashes, stream=stream, - version=version) + version=version, name=name) - def head(self, uri, host=None, strict_slashes=None, version=None): + def head(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=["HEAD"], host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) - def options(self, uri, host=None, strict_slashes=None, version=None): + def options(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=["OPTIONS"], host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) def patch(self, uri, host=None, strict_slashes=None, stream=False, - version=None): + version=None, name=None): return self.route(uri, methods=["PATCH"], host=host, strict_slashes=strict_slashes, stream=stream, - version=version) + version=version, name=name) - def delete(self, uri, host=None, strict_slashes=None, version=None): + def delete(self, uri, host=None, strict_slashes=None, version=None, + name=None): return self.route(uri, methods=["DELETE"], host=host, - strict_slashes=strict_slashes, version=version) + strict_slashes=strict_slashes, version=version, + name=name) diff --git a/sanic/router.py b/sanic/router.py index efc48f37..062fecc8 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -99,7 +99,7 @@ class Router: return name, _type, pattern def add(self, uri, methods, handler, host=None, strict_slashes=False, - version=None): + version=None, name=None): """Add a handler to the route list :param uri: path to match @@ -118,7 +118,7 @@ class Router: else: uri = "/".join(["/v{}".format(str(version)), uri]) # add regular version - self._add(uri, methods, handler, host) + self._add(uri, methods, handler, host, name) if strict_slashes: return @@ -135,12 +135,12 @@ class Router: ) # add version with trailing slash if slash_is_missing: - self._add(uri + '/', methods, handler, host) + self._add(uri + '/', methods, handler, host, name) # add version without trailing slash elif without_slash_is_missing: - self._add(uri[:-1], methods, handler, host) + self._add(uri[:-1], methods, handler, host, name) - def _add(self, uri, methods, handler, host=None): + def _add(self, uri, methods, handler, host=None, name=None): """Add a handler to the route list :param uri: path to match @@ -161,7 +161,7 @@ class Router: "host strings, not {!r}".format(host)) for host_ in host: - self.add(uri, methods, handler, host_) + self.add(uri, methods, handler, host_, name) return # Dict for faster lookups of if method allowed @@ -236,9 +236,9 @@ class Router: # if available if hasattr(handler, '__blueprintname__'): handler_name = '{}.{}'.format( - handler.__blueprintname__, handler.__name__) + handler.__blueprintname__, name or handler.__name__) else: - handler_name = getattr(handler, '__name__', None) + handler_name = name or getattr(handler, '__name__', None) route = Route( handler=handler, methods=methods, pattern=pattern, diff --git a/tests/test_named_routes.py b/tests/test_named_routes.py new file mode 100644 index 00000000..730a2206 --- /dev/null +++ b/tests/test_named_routes.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import asyncio +import pytest + +from sanic import Sanic +from sanic.blueprints import Blueprint +from sanic.response import text +from sanic.router import RouteExists, RouteDoesNotExist +from sanic.exceptions import URLBuildError +from sanic.constants import HTTP_METHODS + + +# ------------------------------------------------------------ # +# UTF-8 +# ------------------------------------------------------------ # + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_named_routes_get(method): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_bp', url_prefix='/bp') + + method = method.lower() + route_name = 'route_{}'.format(method) + route_name2 = 'route2_{}'.format(method) + + func = getattr(app, method) + if callable(func): + @func('/{}'.format(method), version=1, name=route_name) + def handler(request): + return text('OK') + else: + print(func) + raise + + func = getattr(bp, method) + if callable(func): + @func('/{}'.format(method), version=1, name=route_name2) + def handler2(request): + return text('OK') + + else: + print(func) + raise + + app.blueprint(bp) + + assert app.router.routes_all['/v1/{}'.format(method)].name == route_name + + route = app.router.routes_all['/v1/bp/{}'.format(method)] + assert route.name == 'test_bp.{}'.format(route_name2) + + assert app.url_for(route_name) == '/v1/{}'.format(method) + url = app.url_for('test_bp.{}'.format(route_name2)) + assert url == '/v1/bp/{}'.format(method) + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_default_routes_get(): + app = Sanic('test_shorhand_routes_get') + + @app.get('/get') + def handler(request): + return text('OK') + + assert app.router.routes_all['/get'].name == 'handler' + assert app.url_for('handler') == '/get' + + +def test_shorthand_named_routes_get(): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_bp', url_prefix='/bp') + + @app.get('/get', name='route_get') + def handler(request): + return text('OK') + + @bp.get('/get', name='route_bp') + def handler2(request): + return text('Blueprint') + + app.blueprint(bp) + + assert app.router.routes_all['/get'].name == 'route_get' + assert app.url_for('route_get') == '/get' + with pytest.raises(URLBuildError): + app.url_for('handler') + + assert app.router.routes_all['/bp/get'].name == 'test_bp.route_bp' + assert app.url_for('test_bp.route_bp') == '/bp/get' + with pytest.raises(URLBuildError): + app.url_for('test_bp.handler2') + + +def test_shorthand_named_routes_post(): + app = Sanic('test_shorhand_routes_post') + + @app.post('/post', name='route_name') + def handler(request): + return text('OK') + + assert app.router.routes_all['/post'].name == 'route_name' + assert app.url_for('route_name') == '/post' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_put(): + app = Sanic('test_shorhand_routes_put') + + @app.put('/put', name='route_put') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/put'].name == 'route_put' + assert app.url_for('route_put') == '/put' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_delete(): + app = Sanic('test_shorhand_routes_delete') + + @app.delete('/delete', name='route_delete') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/delete'].name == 'route_delete' + assert app.url_for('route_delete') == '/delete' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_patch(): + app = Sanic('test_shorhand_routes_patch') + + @app.patch('/patch', name='route_patch') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/patch'].name == 'route_patch' + assert app.url_for('route_patch') == '/patch' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_head(): + app = Sanic('test_shorhand_routes_head') + + @app.head('/head', name='route_head') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/head'].name == 'route_head' + assert app.url_for('route_head') == '/head' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_options(): + app = Sanic('test_shorhand_routes_options') + + @app.options('/options', name='route_options') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/options'].name == 'route_options' + assert app.url_for('route_options') == '/options' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_named_static_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/test', name='route_test') + async def handler1(request): + return text('OK1') + + @app.route('/pizazz', name='route_pizazz') + async def handler2(request): + return text('OK2') + + assert app.router.routes_all['/test'].name == 'route_test' + assert app.router.routes_static['/test'].name == 'route_test' + assert app.url_for('route_test') == '/test' + with pytest.raises(URLBuildError): + app.url_for('handler1') + + assert app.router.routes_all['/pizazz'].name == 'route_pizazz' + assert app.router.routes_static['/pizazz'].name == 'route_pizazz' + assert app.url_for('route_pizazz') == '/pizazz' + with pytest.raises(URLBuildError): + app.url_for('handler2') + + +def test_named_dynamic_route(): + app = Sanic('test_dynamic_route') + + results = [] + + @app.route('/folder/', name='route_dynamic') + async def handler(request, name): + results.append(name) + return text('OK') + + assert app.router.routes_all['/folder/'].name == 'route_dynamic' + assert app.url_for('route_dynamic', name='test') == '/folder/test' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_named_route_regex(): + app = Sanic('test_dynamic_route_regex') + + @app.route('/folder/', name='route_re') + async def handler(request, folder_id): + return text('OK') + + route = app.router.routes_all['/folder/'] + assert route.name == 'route_re' + assert app.url_for('route_re', folder_id='test') == '/folder/test' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_named_route_path(): + app = Sanic('test_dynamic_route_path') + + @app.route('//info', name='route_dynamic_path') + async def handler(request, path): + return text('OK') + + route = app.router.routes_all['//info'] + assert route.name == 'route_dynamic_path' + assert app.url_for('route_dynamic_path', path='path/1') == '/path/1/info' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_named_route_unhashable(): + app = Sanic('test_dynamic_route_unhashable') + + @app.route('/folder//end/', + name='route_unhashable') + async def handler(request, unhashable): + return text('OK') + + route = app.router.routes_all['/folder//end/'] + assert route.name == 'route_unhashable' + url = app.url_for('route_unhashable', unhashable='test/asdf') + assert url == '/folder/test/asdf/end' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_websocket_named_route(): + app = Sanic('test_websocket_route') + ev = asyncio.Event() + + @app.websocket('/ws', name='route_ws') + async def handler(request, ws): + assert ws.subprotocol is None + ev.set() + + assert app.router.routes_all['/ws'].name == 'route_ws' + assert app.url_for('route_ws') == '/ws' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_websocket_named_route_with_subprotocols(): + app = Sanic('test_websocket_route') + results = [] + + @app.websocket('/ws', subprotocols=['foo', 'bar'], name='route_ws') + async def handler(request, ws): + results.append(ws.subprotocol) + + assert app.router.routes_all['/ws'].name == 'route_ws' + assert app.url_for('route_ws') == '/ws' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_static_add_named_route(): + app = Sanic('test_static_add_route') + + async def handler1(request): + return text('OK1') + + async def handler2(request): + return text('OK2') + + app.add_route(handler1, '/test', name='route_test') + app.add_route(handler2, '/test2', name='route_test2') + + assert app.router.routes_all['/test'].name == 'route_test' + assert app.router.routes_static['/test'].name == 'route_test' + assert app.url_for('route_test') == '/test' + with pytest.raises(URLBuildError): + app.url_for('handler1') + + assert app.router.routes_all['/test2'].name == 'route_test2' + assert app.router.routes_static['/test2'].name == 'route_test2' + assert app.url_for('route_test2') == '/test2' + with pytest.raises(URLBuildError): + app.url_for('handler2') + + +def test_dynamic_add_named_route(): + app = Sanic('test_dynamic_add_route') + + results = [] + + async def handler(request, name): + results.append(name) + return text('OK') + + app.add_route(handler, '/folder/', name='route_dynamic') + assert app.router.routes_all['/folder/'].name == 'route_dynamic' + assert app.url_for('route_dynamic', name='test') == '/folder/test' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_add_named_route_unhashable(): + app = Sanic('test_dynamic_add_route_unhashable') + + async def handler(request, unhashable): + return text('OK') + + app.add_route(handler, '/folder//end/', + name='route_unhashable') + route = app.router.routes_all['/folder//end/'] + assert route.name == 'route_unhashable' + url = app.url_for('route_unhashable', unhashable='folder1') + assert url == '/folder/folder1/end' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_overload_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/overload', methods=['GET'], name='route_first') + async def handler1(request): + return text('OK1') + + @app.route('/overload', methods=['POST', 'PUT'], name='route_second') + async def handler2(request): + return text('OK2') + + @app.route('/overload2', methods=['POST', 'PUT'], name='route_third') + async def handler3(request): + return text('OK2') + + request, response = app.test_client.get(app.url_for('route_first')) + assert response.text == 'OK1' + + request, response = app.test_client.post(app.url_for('route_first')) + assert response.text == 'OK2' + + request, response = app.test_client.put(app.url_for('route_first')) + assert response.text == 'OK2' + + assert app.router.routes_all['/overload'].name == 'route_first' + with pytest.raises(URLBuildError): + app.url_for('handler1') + + with pytest.raises(URLBuildError): + app.url_for('handler2') + + with pytest.raises(URLBuildError): + app.url_for('route_second') + + assert app.url_for('route_third') == '/overload2' + with pytest.raises(URLBuildError): + app.url_for('handler3')