diff --git a/sanic/constants.py b/sanic/constants.py new file mode 100644 index 00000000..0d659f0c --- /dev/null +++ b/sanic/constants.py @@ -0,0 +1 @@ +HTTP_METHODS = ('GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'PATCH', 'DELETE') diff --git a/sanic/router.py b/sanic/router.py index d60dec6d..5ed21136 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -150,7 +150,22 @@ class Router: handler=view, methods=methods.union(route.methods)) return route - route = self.routes_all.get(uri) + if parameters: + # TODO: This is too complex, we need to reduce the complexity + if properties['unhashable']: + routes_to_check = self.routes_always_check + ndx, route = self.check_dynamic_route_exists( + pattern, routes_to_check) + else: + routes_to_check = self.routes_dynamic[url_hash(uri)] + ndx, route = self.check_dynamic_route_exists( + pattern, routes_to_check) + if ndx != -1: + # Pop the ndx of the route, no dups of the same route + routes_to_check.pop(ndx) + else: + route = self.routes_all.get(uri) + if route: route = merge_route(route, methods, handler) else: @@ -166,6 +181,14 @@ class Router: else: self.routes_static[uri] = route + @staticmethod + def check_dynamic_route_exists(pattern, routes_to_check): + for ndx, route in enumerate(routes_to_check): + if route.pattern == pattern: + return ndx, route + else: + return -1, None + def remove(self, uri, clean_cache=True, host=None): if host is not None: uri = host + uri @@ -211,29 +234,40 @@ class Router: url = host + url # Check against known static routes route = self.routes_static.get(url) + method_not_supported = InvalidUsage( + 'Method {} not allowed for URL {}'.format( + method, url), status_code=405) if route: + if route.methods and method not in route.methods: + raise method_not_supported match = route.pattern.match(url) else: + route_found = False # Move on to testing all regex routes for route in self.routes_dynamic[url_hash(url)]: match = route.pattern.match(url) - if match: + route_found |= match is not None + # Do early method checking + if match and method in route.methods: break else: # Lastly, check against all regex routes that cannot be hashed for route in self.routes_always_check: match = route.pattern.match(url) - if match: + route_found |= match is not None + # Do early method checking + if match and method in route.methods: break else: + # Route was found but the methods didn't match + if route_found: + raise method_not_supported raise NotFound('Requested URL {} not found'.format(url)) - if route.methods and method not in route.methods: - raise InvalidUsage( - 'Method {} not allowed for URL {}'.format( - method, url), status_code=405) - kwargs = {p.name: p.cast(value) for value, p in zip(match.groups(1), route.parameters)} - return route.handler, [], kwargs + route_handler = route.handler + if hasattr(route_handler, 'handlers'): + route_handler = route_handler.handlers[method] + return route_handler, [], kwargs diff --git a/sanic/sanic.py b/sanic/sanic.py index ea2e8bef..a3342653 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -7,6 +7,7 @@ from traceback import format_exc import warnings from .config import Config +from .constants import HTTP_METHODS from .exceptions import Handler from .exceptions import ServerError from .log import log @@ -91,7 +92,10 @@ class Sanic: def patch(self, uri, host=None): return self.route(uri, methods=["PATCH"], host=host) - def add_route(self, handler, uri, methods=None, host=None): + def delete(self, uri, host=None): + return self.route(uri, methods=["DELETE"], host=host) + + def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None): """ A helper method to register class instance or functions as a handler to the application url @@ -99,9 +103,13 @@ class Sanic: :param handler: function or class instance :param uri: path of the URL - :param methods: list or tuple of methods allowed + :param methods: list or tuple of methods allowed, these are overridden + if using a HTTPMethodView :return: function or class instance """ + # Handle HTTPMethodView differently + if hasattr(handler, 'view_class'): + methods = frozenset(HTTP_METHODS) self.route(uri=uri, methods=methods, host=host)(handler) return handler diff --git a/sanic/utils.py b/sanic/utils.py index 1943652c..644a2a22 100644 --- a/sanic/utils.py +++ b/sanic/utils.py @@ -9,7 +9,8 @@ async def local_request(method, uri, cookies=None, *args, **kwargs): url = 'http://{host}:{port}{uri}'.format(host=HOST, port=PORT, uri=uri) log.info(url) async with aiohttp.ClientSession(cookies=cookies) as session: - async with getattr(session, method)(url, *args, **kwargs) as response: + async with getattr( + session, method.lower())(url, *args, **kwargs) as response: response.text = await response.text() response.body = await response.read() return response diff --git a/tests/test_dynamic_routes.py b/tests/test_dynamic_routes.py new file mode 100644 index 00000000..24ba79b5 --- /dev/null +++ b/tests/test_dynamic_routes.py @@ -0,0 +1,46 @@ +from sanic import Sanic +from sanic.response import text +from sanic.utils import sanic_endpoint_test +from sanic.router import RouteExists +import pytest + + +@pytest.mark.parametrize("method,attr, expected", [ + ("get", "text", "OK1 test"), + ("post", "text", "OK2 test"), + ("put", "text", "OK2 test"), + ("delete", "status", 405), +]) +def test_overload_dynamic_routes(method, attr, expected): + app = Sanic('test_dynamic_route') + + @app.route('/overload/', methods=['GET']) + async def handler1(request, param): + return text('OK1 ' + param) + + @app.route('/overload/', methods=['POST', 'PUT']) + async def handler2(request, param): + return text('OK2 ' + param) + + request, response = sanic_endpoint_test( + app, method, uri='/overload/test') + assert getattr(response, attr) == expected + + +def test_overload_dynamic_routes_exist(): + app = Sanic('test_dynamic_route') + + @app.route('/overload/', methods=['GET']) + async def handler1(request, param): + return text('OK1 ' + param) + + @app.route('/overload/', methods=['POST', 'PUT']) + async def handler2(request, param): + return text('OK2 ' + param) + + # if this doesn't raise an error, than at least the below should happen: + # assert response.text == 'Duplicated' + with pytest.raises(RouteExists): + @app.route('/overload/', methods=['PUT', 'DELETE']) + async def handler3(request): + return text('Duplicated') diff --git a/tests/test_views.py b/tests/test_views.py index 24647cf6..4e5b17f0 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,43 +1,45 @@ +import pytest as pytest + from sanic import Sanic from sanic.response import text, HTTPResponse from sanic.views import HTTPMethodView from sanic.blueprints import Blueprint from sanic.request import Request from sanic.utils import sanic_endpoint_test +from sanic.constants import HTTP_METHODS -def test_methods(): +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_methods(method): app = Sanic('test_methods') class DummyView(HTTPMethodView): def get(self, request): - return text('I am get method') + return text('', headers={'method': 'GET'}) def post(self, request): - return text('I am post method') + return text('', headers={'method': 'POST'}) def put(self, request): - return text('I am put method') + return text('', headers={'method': 'PUT'}) + + def head(self, request): + return text('', headers={'method': 'HEAD'}) + + def options(self, request): + return text('', headers={'method': 'OPTIONS'}) def patch(self, request): - return text('I am patch method') + return text('', headers={'method': 'PATCH'}) def delete(self, request): - return text('I am delete method') + return text('', headers={'method': 'DELETE'}) app.add_route(DummyView.as_view(), '/') - request, response = sanic_endpoint_test(app, method="get") - assert response.text == 'I am get method' - request, response = sanic_endpoint_test(app, method="post") - assert response.text == 'I am post method' - request, response = sanic_endpoint_test(app, method="put") - assert response.text == 'I am put method' - request, response = sanic_endpoint_test(app, method="patch") - assert response.text == 'I am patch method' - request, response = sanic_endpoint_test(app, method="delete") - assert response.text == 'I am delete method' + request, response = sanic_endpoint_test(app, method=method) + assert response.headers['method'] == method def test_unexisting_methods():