diff --git a/sanic/router.py b/sanic/router.py index 24d0438f..7ae28f09 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -4,8 +4,7 @@ from functools import lru_cache from .exceptions import NotFound, InvalidUsage from .views import CompositionView -Route = namedtuple( - 'Route', +Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters', 'name']) Parameter = namedtuple('Parameter', ['name', 'cast']) @@ -70,6 +69,28 @@ class Router: self.routes_always_check = [] self.hosts = None + def __str__(self): + """ + The typical user inspecting the router will likely want to see + the routes available. Provide a simple representation. + """ + def _route_to_str(uri, route): + out = 'name={0.name}, methods={0.methods}, URI={1}>\n'.format( + route, uri) + + if route.handler.__doc__: + out += '{}\n'.format(route.handler.__doc__) + + out += '\n' + + return out + + out = '' + for uri, route in self.routes_all.items(): + out += _route_to_str(uri, route) + + return out + def parse_parameter_string(self, parameter_string): """ Parse a parameter string into its constituent name, type, and pattern @@ -130,11 +151,16 @@ class Router: properties = {"unhashable": None} def add_parameter(match): + # We could receive NAME or NAME:PATTERN name = match.group(1) - name, _type, pattern = self.parse_parameter_string(name) + pattern = 'string' + if ':' in name: + name, pattern = name.split(':', 1) - parameter = Parameter( - name=name, cast=_type) + default = (str, pattern) + # Pull from pre-configured types + _type, pattern = REGEX_TYPES.get(pattern, default) + parameter = Parameter(name=name, cast=_type) parameters.append(parameter) # Mark the whole route as unhashable if it has the hash key in it @@ -146,7 +172,7 @@ class Router: return '({})'.format(pattern) - pattern_string = re.sub(self.parameter_pattern, add_parameter, uri) + pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) pattern = re.compile(r'^{}$'.format(pattern_string)) def merge_route(route, methods, handler): diff --git a/sanic/sanic.py b/sanic/sanic.py index 4ad4734a..079049ca 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -17,6 +17,7 @@ from .response import HTTPResponse from .router import Router from .server import serve, serve_multiple, HttpProtocol from .static import register as static_register +from .views import CompositionView class Sanic: @@ -120,7 +121,16 @@ class Sanic: """ # Handle HTTPMethodView differently if hasattr(handler, 'view_class'): - methods = frozenset(HTTP_METHODS) + methods = set() + + for method in HTTP_METHODS: + if getattr(handler.view_class, method.lower(), None): + methods.add(method) + + # handle composition view differently + if isinstance(handler, CompositionView): + methods = handler.handlers.keys() + self.route(uri=uri, methods=methods, host=host)(handler) return handler diff --git a/sanic/views.py b/sanic/views.py index 5d8e9d40..d6da9145 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -1,4 +1,5 @@ from .exceptions import InvalidUsage +from .constants import HTTP_METHODS class HTTPMethodView: @@ -40,11 +41,7 @@ class HTTPMethodView: def dispatch_request(self, request, *args, **kwargs): handler = getattr(self, request.method.lower(), None) - if handler: - return handler(request, *args, **kwargs) - raise InvalidUsage( - 'Method {} not allowed for URL {}'.format( - request.method, request.url), status_code=405) + return handler(request, *args, **kwargs) @classmethod def as_view(cls, *class_args, **class_kwargs): @@ -89,15 +86,15 @@ class CompositionView: def add(self, methods, handler): for method in methods: + if method not in HTTP_METHODS: + raise InvalidUsage( + '{} is not a valid HTTP method.'.format(method)) + if method in self.handlers: - raise KeyError( - 'Method {} already is registered.'.format(method)) + raise InvalidUsage( + 'Method {} is already 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) + handler = self.handlers[request.method.upper()] return handler(request, *args, **kwargs) diff --git a/tests/test_views.py b/tests/test_views.py index 4e5b17f0..456b714d 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,8 +1,9 @@ import pytest as pytest from sanic import Sanic +from sanic.exceptions import InvalidUsage from sanic.response import text, HTTPResponse -from sanic.views import HTTPMethodView +from sanic.views import HTTPMethodView, CompositionView from sanic.blueprints import Blueprint from sanic.request import Request from sanic.utils import sanic_endpoint_test @@ -196,3 +197,56 @@ def test_with_decorator(): request, response = sanic_endpoint_test(app, method="get") assert response.text == 'I am get method' assert results[0] == 1 + + +def test_composition_view_rejects_incorrect_methods(): + def foo(request): + return text('Foo') + + view = CompositionView() + + with pytest.raises(InvalidUsage) as e: + view.add(['GET', 'FOO'], foo) + + assert str(e.value) == 'FOO is not a valid HTTP method.' + + +def test_composition_view_rejects_duplicate_methods(): + def foo(request): + return text('Foo') + + view = CompositionView() + + with pytest.raises(InvalidUsage) as e: + view.add(['GET', 'POST', 'GET'], foo) + + assert str(e.value) == 'Method GET is already registered.' + + +def test_composition_view_runs_methods_as_expected(): + app = Sanic('test_composition_view') + + view = CompositionView() + view.add(['GET', 'POST', 'PUT'], lambda x: text('first method')) + view.add(['DELETE', 'PATCH'], lambda x: text('second method')) + + app.add_route(view, '/') + + for method in ['GET', 'POST', 'PUT']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.text == 'first method' + + for method in ['DELETE', 'PATCH']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.text == 'second method' + +def test_composition_view_rejects_invalid_methods(): + app = Sanic('test_composition_view') + + view = CompositionView() + view.add(['GET', 'POST', 'PUT'], lambda x: text('first method')) + + app.add_route(view, '/') + for method in ['DELETE', 'PATCH']: + request, response = sanic_endpoint_test(app, uri='/', method=method) + assert response.status == 405