diff --git a/examples/vhosts.py b/examples/vhosts.py index 40dc7ba5..810dc513 100644 --- a/examples/vhosts.py +++ b/examples/vhosts.py @@ -11,9 +11,16 @@ from sanic.blueprints import Blueprint app = Sanic() bp = Blueprint("bp", host="bp.example.com") +@app.route('/', host=["example.com", + "somethingelse.com", + "therestofyourdomains.com"]) +async def hello(request): + return text("Some defaults") + @app.route('/', host="example.com") async def hello(request): return text("Answer") + @app.route('/', host="sub.example.com") async def hello(request): return text("42") diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 430f1d29..f1d81878 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -173,6 +173,7 @@ class Handler: try: response = handler(request=request, exception=exception) except: + log.error(format_exc()) if self.sanic.debug: response_message = ( 'Exception raised in exception handler "{}" ' @@ -185,6 +186,7 @@ class Handler: return response def default(self, request, exception): + log.error(format_exc()) if issubclass(type(exception), SanicException): return text( 'Error: {}'.format(exception), diff --git a/sanic/router.py b/sanic/router.py index 9ab01b1c..e817be9c 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']) @@ -75,11 +76,15 @@ class Router: if self.hosts is None: self.hosts = set(host) else: + if isinstance(host, list): + host = set(host) self.hosts.add(host) - uri = host + uri - - if uri in self.routes_all: - raise RouteExists("Route already registered: {}".format(uri)) + if isinstance(host, str): + uri = host + uri + else: + for h in host: + self.add(uri, methods, handler, h) + return # Dict for faster lookups of if method allowed if methods: @@ -113,9 +118,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/sanic.py b/sanic/sanic.py index 4cd1d32a..5f8bf650 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -232,7 +232,7 @@ class Sanic: e, format_exc())) else: response = HTTPResponse( - "An error occured while handling an error") + "An error occurred while handling an error") response_callback(response) @@ -241,10 +241,10 @@ class Sanic: # -------------------------------------------------------------------- # def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None, - after_start=None, before_stop=None, after_stop=None, ssl=None, sock=None, workers=1, loop=None, protocol=HttpProtocol, backlog=100, stop_event=None): + """ Runs the HTTP Server and listens until keyboard interrupt or term signal. On termination, drains connections before closing. diff --git a/sanic/server.py b/sanic/server.py index fb5d4c16..8285cfe0 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -1,4 +1,5 @@ import asyncio +import traceback from functools import partial from inspect import isawaitable from signal import SIGINT, SIGTERM @@ -189,9 +190,15 @@ class HttpProtocol(asyncio.Protocol): "Writing error failed, connection closed {}".format(e)) def bail_out(self, message): - exception = ServerError(message) - self.write_error(exception) - log.error(message) + if self.transport.is_closing(): + log.error( + "Connection closed before error was sent to user @ {}".format( + self.transport.get_extra_info('peername'))) + log.debug('Error experienced:\n{}'.format(traceback.format_exc())) + else: + exception = ServerError(message) + self.write_error(exception) + log.error(message) def cleanup(self): self.parser = None 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 diff --git a/tests/test_vhosts.py b/tests/test_vhosts.py index 7bbbb813..660ebb5f 100644 --- a/tests/test_vhosts.py +++ b/tests/test_vhosts.py @@ -4,7 +4,7 @@ from sanic.utils import sanic_endpoint_test def test_vhosts(): - app = Sanic('test_text') + app = Sanic('test_vhosts') @app.route('/', host="example.com") async def handler(request): @@ -21,3 +21,19 @@ def test_vhosts(): headers = {"Host": "subdomain.example.com"} request, response = sanic_endpoint_test(app, headers=headers) assert response.text == "You're at subdomain.example.com!" + + +def test_vhosts_with_list(): + app = Sanic('test_vhosts') + + @app.route('/', host=["hello.com", "world.com"]) + async def handler(request): + return text("Hello, world!") + + headers = {"Host": "hello.com"} + request, response = sanic_endpoint_test(app, headers=headers) + assert response.text == "Hello, world!" + + headers = {"Host": "world.com"} + request, response = sanic_endpoint_test(app, headers=headers) + assert response.text == "Hello, world!"