diff --git a/README.rst b/README.rst index a95081cc..ab25debf 100644 --- a/README.rst +++ b/README.rst @@ -56,6 +56,21 @@ Hello World Example if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) +SSL Example +----------- + +Optionally pass in an SSLContext: + +.. code:: python + + import ssl + certificate = "/path/to/certificate" + keyfile = "/path/to/keyfile" + context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certificate, keyfile=keyfile) + + app.run(host="0.0.0.0", port=8443, ssl=context) + Installation ------------ 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/blueprints.py b/sanic/blueprints.py index 583aa244..416cad5d 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -3,6 +3,7 @@ from collections import defaultdict class BlueprintSetup: """ + Creates a blueprint state like object. """ def __init__(self, blueprint, app, options): @@ -32,13 +33,13 @@ class BlueprintSetup: def add_exception(self, handler, *args, **kwargs): """ - Registers exceptions to sanic + Registers exceptions to sanic. """ self.app.exception(*args, **kwargs)(handler) def add_static(self, uri, file_or_directory, *args, **kwargs): """ - Registers static files to sanic + Registers static files to sanic. """ if self.url_prefix: uri = self.url_prefix + uri @@ -47,7 +48,7 @@ class BlueprintSetup: def add_middleware(self, middleware, *args, **kwargs): """ - Registers middleware to sanic + Registers middleware to sanic. """ if args or kwargs: self.app.middleware(*args, **kwargs)(middleware) @@ -77,11 +78,13 @@ class Blueprint: def make_setup_state(self, app, options): """ + Returns a new BlueprintSetup object """ return BlueprintSetup(self, app, options) def register(self, app, options): """ + Registers the blueprint to the sanic app. """ state = self.make_setup_state(app, options) for deferred in self.deferred_functions: @@ -89,6 +92,9 @@ class Blueprint: def route(self, uri, methods=None, host=None): """ + Creates a blueprint route from a decorated function. + :param uri: Endpoint at which the route will be accessible. + :param methods: List of acceptable HTTP methods. """ def decorator(handler): self.record(lambda s: s.add_route(handler, uri, methods, host)) @@ -97,12 +103,18 @@ class Blueprint: def add_route(self, handler, uri, methods=None, host=None): """ + Creates a blueprint route from a function. + :param handler: Function to handle uri request. + :param uri: Endpoint at which the route will be accessible. + :param methods: List of acceptable HTTP methods. """ self.record(lambda s: s.add_route(handler, uri, methods, host)) return handler def listener(self, event): """ + Create a listener from a decorated function. + :param event: Event to listen to. """ def decorator(listener): self.listeners[event].append(listener) @@ -111,6 +123,7 @@ class Blueprint: def middleware(self, *args, **kwargs): """ + Creates a blueprint middleware from a decorated function. """ def register_middleware(middleware): self.record( @@ -127,6 +140,7 @@ class Blueprint: def exception(self, *args, **kwargs): """ + Creates a blueprint exception from a decorated function. """ def decorator(handler): self.record(lambda s: s.add_exception(handler, *args, **kwargs)) @@ -135,6 +149,9 @@ class Blueprint: def static(self, uri, file_or_directory, *args, **kwargs): """ + Creates a blueprint static route from a decorated function. + :param uri: Endpoint at which the route will be accessible. + :param file_or_directory: Static asset. """ self.record( lambda s: s.add_static(uri, file_or_directory, *args, **kwargs)) diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 665c5675..1e8ec639 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -174,6 +174,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 "{}" ' @@ -186,6 +187,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/response.py b/sanic/response.py index ba10b8c4..244bc1b3 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -143,21 +143,45 @@ class HTTPResponse: def json(body, status=200, headers=None): + """ + Returns response object with body in json format. + :param body: Response data to be serialized. + :param status: Response code. + :param headers: Custom Headers. + """ return HTTPResponse(json_dumps(body), headers=headers, status=status, content_type="application/json") def text(body, status=200, headers=None): + """ + Returns response object with body in text format. + :param body: Response data to be encoded. + :param status: Response code. + :param headers: Custom Headers. + """ return HTTPResponse(body, status=status, headers=headers, content_type="text/plain; charset=utf-8") def html(body, status=200, headers=None): + """ + Returns response object with body in html format. + :param body: Response data to be encoded. + :param status: Response code. + :param headers: Custom Headers. + """ return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8") async def file(location, mime_type=None, headers=None): + """ + Returns response object with file data. + :param location: Location of file on system. + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + """ filename = path.split(location)[-1] async with open_async(location, mode='rb') as _file: diff --git a/sanic/router.py b/sanic/router.py index 271ebed4..ec67f690 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']) @@ -84,11 +85,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: @@ -122,9 +127,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 8aca0e4a..666365b6 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -237,7 +237,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) @@ -246,9 +246,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, sock=None, - workers=1, loop=None, protocol=HttpProtocol, backlog=100, - stop_event=None, logger=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. @@ -264,7 +265,7 @@ class Sanic: received before it is respected :param after_stop: Functions to be executed when all requests are complete - + :param ssl: SSLContext for SSL encryption of worker(s) :param sock: Socket for the server to accept connections from :param workers: Number of processes received before it is respected @@ -285,6 +286,7 @@ class Sanic: 'host': host, 'port': port, 'sock': sock, + 'ssl': ssl, 'debug': debug, 'request_handler': self.handle_request, 'error_handler': self.error_handler, @@ -322,7 +324,11 @@ class Sanic: log.debug(self.config.LOGO) # Serve - log.info('Goin\' Fast @ http://{}:{}'.format(host, port)) + if ssl is None: + proto = "http" + else: + proto = "https" + log.info('Goin\' Fast @ {}://{}:{}'.format(proto, host, port)) try: if workers == 1: diff --git a/sanic/server.py b/sanic/server.py index 013608f6..f796c102 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -248,7 +248,7 @@ def trigger_events(events, loop): def serve(host, port, request_handler, error_handler, before_start=None, after_start=None, before_stop=None, after_stop=None, debug=False, - request_timeout=60, sock=None, request_max_size=None, + request_timeout=60, ssl=None, sock=None, request_max_size=None, reuse_port=False, loop=None, protocol=HttpProtocol, backlog=100): """ Starts asynchronous HTTP Server on an individual process. @@ -269,6 +269,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, argument `loop` :param debug: Enables debug output (slows server) :param request_timeout: time in seconds + :param ssl: SSLContext :param sock: Socket for the server to accept connections from :param request_max_size: size in bytes, `None` for no limit :param reuse_port: `True` for multiple workers @@ -301,6 +302,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, server, host, port, + ssl=ssl, reuse_port=reuse_port, sock=sock, backlog=backlog diff --git a/sanic/views.py b/sanic/views.py index 9baf551c..407ba136 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -65,3 +65,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!"