From a03f216f42f36cb7acdaca7259d031e4aa10f21a Mon Sep 17 00:00:00 2001 From: Sean Parsons Date: Sun, 25 Dec 2016 00:47:51 -0500 Subject: [PATCH 01/10] Added additional docstrings to blueprints.py --- sanic/blueprints.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 92e376f1..af5ab337 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): @@ -29,13 +30,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 @@ -44,7 +45,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) @@ -73,11 +74,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: @@ -85,6 +88,9 @@ class Blueprint: def route(self, uri, methods=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)) @@ -93,12 +99,18 @@ class Blueprint: def add_route(self, handler, uri, methods=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)) 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) @@ -107,6 +119,7 @@ class Blueprint: def middleware(self, *args, **kwargs): """ + Creates a blueprint middleware from a decorated function. """ def register_middleware(middleware): self.record( @@ -123,6 +136,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)) @@ -131,6 +145,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)) From 2b10860c32a6a96dbb66199027480068b0d9b49a Mon Sep 17 00:00:00 2001 From: Sean Parsons Date: Sun, 25 Dec 2016 01:05:26 -0500 Subject: [PATCH 02/10] Added docstrings to sanic.response.py for issue 41 --- sanic/response.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/sanic/response.py b/sanic/response.py index 2c4c7f27..5031b1c8 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -139,21 +139,45 @@ class HTTPResponse: def json(body, status=200, headers=None): + """ + Returns serialized python object to 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 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 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 file with mime_type. + :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: From a486fb99a9cdc381ac39c0ae9f733432aa0af837 Mon Sep 17 00:00:00 2001 From: Sean Parsons Date: Sun, 25 Dec 2016 01:06:40 -0500 Subject: [PATCH 03/10] Updated json function docstrings to be more consistent. --- sanic/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response.py b/sanic/response.py index 5031b1c8..1bae348d 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -140,7 +140,7 @@ class HTTPResponse: def json(body, status=200, headers=None): """ - Returns serialized python object to json format. + Returns body in json format. :param body: Response data to be serialized. :param status: Response code. :param headers: Custom Headers. From d5ad5e46da53bb47a204585929178892aa162053 Mon Sep 17 00:00:00 2001 From: Sean Parsons Date: Sun, 25 Dec 2016 01:24:17 -0500 Subject: [PATCH 04/10] Update response docstrings to be explicit on whats returned. --- sanic/response.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sanic/response.py b/sanic/response.py index 1bae348d..407ec9f2 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -140,7 +140,7 @@ class HTTPResponse: def json(body, status=200, headers=None): """ - Returns body in json format. + Returns response object with body in json format. :param body: Response data to be serialized. :param status: Response code. :param headers: Custom Headers. @@ -151,7 +151,7 @@ def json(body, status=200, headers=None): def text(body, status=200, headers=None): """ - Returns body in text format. + Returns response object with body in text format. :param body: Response data to be encoded. :param status: Response code. :param headers: Custom Headers. @@ -162,7 +162,7 @@ def text(body, status=200, headers=None): def html(body, status=200, headers=None): """ - Returns body in html format. + Returns response object with body in html format. :param body: Response data to be encoded. :param status: Response code. :param headers: Custom Headers. @@ -173,7 +173,7 @@ def html(body, status=200, headers=None): async def file(location, mime_type=None, headers=None): """ - Returns file with mime_type. + Returns response object with file data. :param location: Location of file on system. :param mime_type: Specific mime_type. :param headers: Custom Headers. From 49fdc6563f5394a44f88cf4095de9e0e96fd5698 Mon Sep 17 00:00:00 2001 From: Matt Daue Date: Sat, 14 Jan 2017 07:16:59 -0500 Subject: [PATCH 05/10] Add SSL to server Add ssl variable passthrough to following: -- sanic.run -- server.serve Add ssl variable to loop.create_server to enable built-in async context socket wrapper Update documentation Tested with worker = 1, and worker = 2. Signed-off-by: Matt Daue --- README.md | 12 ++++++++++++ sanic/sanic.py | 14 ++++++++++---- sanic/server.py | 4 +++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 34565545..1d9a6c9f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,18 @@ if __name__ == "__main__": ## Installation * `python -m pip install sanic` +## Use SSL + * Optionally pass in an SSLContext: +``` +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) +``` + ## Documentation * [Getting started](docs/getting_started.md) * [Request Data](docs/request_data.md) diff --git a/sanic/sanic.py b/sanic/sanic.py index 6926050c..ff6e468e 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -245,9 +245,9 @@ 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): + 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. @@ -262,6 +262,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 @@ -278,6 +279,7 @@ class Sanic: 'host': host, 'port': port, 'sock': sock, + 'ssl': ssl, 'debug': debug, 'request_handler': self.handle_request, 'error_handler': self.error_handler, @@ -315,7 +317,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 ec207d26..4f0cfa97 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -225,7 +225,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. @@ -243,6 +243,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, received after it is respected. Takes single argumenet `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 @@ -275,6 +276,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 From 11f3c79a77c3afb63c3d21c51ceec96f818d40e9 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Fri, 13 Jan 2017 21:18:28 +0900 Subject: [PATCH 06/10] Feature: Routing overload When user specifies HTTP methods to function handlers, it automatically will be overloaded unless they duplicate. Example: # This is a new route. It works as before. @app.route('/overload', methods=['GET']) async def handler1(request): return text('OK1') # This is the exiting route but a new method. They are merged and # work as combined. The route will serve all of GET, POST and PUT. @app.route('/overload', methods=['POST', 'PUT']) async def handler2(request): return text('OK2') # This is the existing route and PUT method is the duplicated method. # It raises RouteExists. @app.route('/overload', methods=['PUT', 'DELETE']) async def handler3(request): return text('Duplicated') --- sanic/router.py | 36 ++++++++++++++++++++----- sanic/views.py | 35 ++++++++++++++++++++++++ tests/test_routes.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) 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 From 2c1ff5bf5df980ea23465b189ce3deddac672248 Mon Sep 17 00:00:00 2001 From: Raphael Deem Date: Wed, 18 Jan 2017 19:40:20 -0800 Subject: [PATCH 07/10] allow using a list of hosts on a route --- examples/vhosts.py | 7 +++++++ sanic/router.py | 9 ++++++++- tests/test_vhosts.py | 18 +++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) 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/router.py b/sanic/router.py index a8ad7c13..e817be9c 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -76,8 +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 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: 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!" From bb83a25a52d187cd1577341e709f661ef4215dde Mon Sep 17 00:00:00 2001 From: Raphael Deem Date: Wed, 18 Jan 2017 21:45:30 -0800 Subject: [PATCH 08/10] remove logger from run --- sanic/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index 5f8aeeb2..94fcd983 100644 --- a/sanic/sanic.py +++ b/sanic/sanic.py @@ -243,7 +243,7 @@ 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): + stop_event=None): """ Runs the HTTP Server and listens until keyboard interrupt or term signal. On termination, drains connections before closing. From cc43ee3b3db08c8b05ca8c9d67514f4b404e5a49 Mon Sep 17 00:00:00 2001 From: zkanda Date: Tue, 17 Jan 2017 19:17:42 +0800 Subject: [PATCH 09/10] Always log of there's an exception occured. --- sanic/exceptions.py | 2 ++ 1 file changed, 2 insertions(+) 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), From ba606598941d4c77771112158d44daaffb5dd440 Mon Sep 17 00:00:00 2001 From: James Michael DuPont Date: Thu, 19 Jan 2017 04:04:16 -0500 Subject: [PATCH 10/10] untie --- sanic/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/sanic.py b/sanic/sanic.py index 94fcd983..5e00b55d 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)