diff --git a/sanic/router.py b/sanic/router.py index 9ab01b1c..a8ad7c13 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -3,6 +3,7 @@ from collections import defaultdict, namedtuple from functools import lru_cache from .config import Config from .exceptions import NotFound, InvalidUsage +from .views import CompositionView Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters']) Parameter = namedtuple('Parameter', ['name', 'cast']) @@ -78,9 +79,6 @@ class Router: self.hosts.add(host) uri = host + uri - if uri in self.routes_all: - raise RouteExists("Route already registered: {}".format(uri)) - # Dict for faster lookups of if method allowed if methods: methods = frozenset(methods) @@ -113,9 +111,35 @@ class Router: pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) pattern = re.compile(r'^{}$'.format(pattern_string)) - route = Route( - handler=handler, methods=methods, pattern=pattern, - parameters=parameters) + def merge_route(route, methods, handler): + # merge to the existing route when possible. + if not route.methods or not methods: + # method-unspecified routes are not mergeable. + raise RouteExists( + "Route already registered: {}".format(uri)) + elif route.methods.intersection(methods): + # already existing method is not overloadable. + duplicated = methods.intersection(route.methods) + raise RouteExists( + "Route already registered: {} [{}]".format( + uri, ','.join(list(duplicated)))) + if isinstance(route.handler, CompositionView): + view = route.handler + else: + view = CompositionView() + view.add(route.methods, route.handler) + view.add(methods, handler) + route = route._replace( + handler=view, methods=methods.union(route.methods)) + return route + + route = self.routes_all.get(uri) + if route: + route = merge_route(route, methods, handler) + else: + route = Route( + handler=handler, methods=methods, pattern=pattern, + parameters=parameters) self.routes_all[uri] = route if properties['unhashable']: diff --git a/sanic/views.py b/sanic/views.py index 0222b96f..640165fe 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -61,3 +61,38 @@ class HTTPMethodView: view.__doc__ = cls.__doc__ view.__module__ = cls.__module__ return view + + +class CompositionView: + """ Simple method-function mapped view for the sanic. + You can add handler functions to methods (get, post, put, patch, delete) + for every HTTP method you want to support. + + For example: + view = CompositionView() + view.add(['GET'], lambda request: text('I am get method')) + view.add(['POST', 'PUT'], lambda request: text('I am post/put method')) + + etc. + + If someone tries to use a non-implemented method, there will be a + 405 response. + """ + + def __init__(self): + self.handlers = {} + + def add(self, methods, handler): + for method in methods: + if method in self.handlers: + raise KeyError( + 'Method {} already is registered.'.format(method)) + self.handlers[method] = handler + + def __call__(self, request, *args, **kwargs): + handler = self.handlers.get(request.method.upper(), None) + if handler is None: + raise InvalidUsage( + 'Method {} not allowed for URL {}'.format( + request.method, request.url), status_code=405) + return handler(request, *args, **kwargs) diff --git a/tests/test_routes.py b/tests/test_routes.py index 149c71f9..9c671829 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -463,3 +463,67 @@ def test_remove_route_without_clean_cache(): request, response = sanic_endpoint_test(app, uri='/test') assert response.status == 200 + + +def test_overload_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/overload', methods=['GET']) + async def handler1(request): + return text('OK1') + + @app.route('/overload', methods=['POST', 'PUT']) + async def handler2(request): + return text('OK2') + + request, response = sanic_endpoint_test(app, 'get', uri='/overload') + assert response.text == 'OK1' + + request, response = sanic_endpoint_test(app, 'post', uri='/overload') + assert response.text == 'OK2' + + request, response = sanic_endpoint_test(app, 'put', uri='/overload') + assert response.text == 'OK2' + + request, response = sanic_endpoint_test(app, 'delete', uri='/overload') + assert response.status == 405 + + with pytest.raises(RouteExists): + @app.route('/overload', methods=['PUT', 'DELETE']) + async def handler3(request): + return text('Duplicated') + + +def test_unmergeable_overload_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/overload_whole') + async def handler1(request): + return text('OK1') + + with pytest.raises(RouteExists): + @app.route('/overload_whole', methods=['POST', 'PUT']) + async def handler2(request): + return text('Duplicated') + + request, response = sanic_endpoint_test(app, 'get', uri='/overload_whole') + assert response.text == 'OK1' + + request, response = sanic_endpoint_test(app, 'post', uri='/overload_whole') + assert response.text == 'OK1' + + + @app.route('/overload_part', methods=['GET']) + async def handler1(request): + return text('OK1') + + with pytest.raises(RouteExists): + @app.route('/overload_part') + async def handler2(request): + return text('Duplicated') + + request, response = sanic_endpoint_test(app, 'get', uri='/overload_part') + assert response.text == 'OK1' + + request, response = sanic_endpoint_test(app, 'post', uri='/overload_part') + assert response.status == 405