├── .gitignore ├── .travis.yml ├── LICENCE ├── README.md ├── examples └── test1.py ├── requirements-dev.txt ├── requirements.txt ├── sanic_dispatcher ├── __init__.py ├── extension.py └── version.py ├── setup.cfg ├── setup.py ├── test ├── conftest.py └── test_basic.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | *.pyc 4 | venv/ 5 | venv2/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | dist: xenial 4 | cache: 5 | directories: 6 | - $HOME/.cache/pip 7 | matrix: 8 | include: 9 | - env: TOXENV=py35-sanic0083 10 | python: 3.5 11 | dist: xenial 12 | sudo: true 13 | name: "Python 3.5 (sanic 0.8.3)" 14 | - env: TOXENV=py36-sanic1812,py36-sanic1912,py36-sanic2012 15 | python: 3.6 16 | dist: xenial 17 | sudo: true 18 | name: "Python 3.6 (sanic 18.12, 19.12, 20.12)" 19 | - env: TOXENV=py37-sanic1912,py37-sanic2012,py37-sanic2103 20 | python: 3.7 21 | dist: xenial 22 | sudo: true 23 | name: "Python 3.7 (sanic 19.12, 20.12, 21.03)" 24 | - env: TOXENV=py38-sanic1912,py38-sanic2012,py38-sanic2103 25 | python: 3.8 26 | dist: bionic 27 | sudo: true 28 | name: "Python 3.8 (sanic 19.12, 20.12, 21.03)" 29 | - env: TOXENV=py39-sanic1912,py39-sanic2012,py39-sanic2103 30 | python: 3.9 31 | dist: bionic 32 | sudo: true 33 | name: "Python 3.9 (sanic 19.12, 20.12, 21.03)" 34 | 35 | install: 36 | - pip install -U tox 37 | - pip install codecov 38 | script: travis_retry tox 39 | after_success: 40 | - codecov 41 | deploy: 42 | provider: pypi 43 | user: ashleysommer 44 | distributions: sdist bdist_wheel 45 | on: 46 | tags: true 47 | repo: ashleysommer/sanic-dispatcher 48 | password: 49 | secure: lq8L1Rc6jJW6URaZcmu5YOa9z8MqQkCiIxfivZlYBjbCkZLenU4/Hc7bK/ORVg+TPJZiyrfxobStFp1cX460zgQRw+3OW8f7whtRmj0qxLq9SF9szpORLrDcvSzHV/xU8GF6xAyJ83VS5UWC2TiA4yHIQyi7YkLYh9zdC370xkXMwKLQ+G2Bmqx5Y84h1wKYtpNU6RdrZiwcBTZEvZdWaoEjw5gZsd5wW8RmWDewS2SE2P6m7FXZwShB9XRGKkeC+UAXzDkp9DpZa94PQTktwXaM2yRo5Y2t1N2BBKzTT72ikFv/xr8vov157z0pdcOiEx7Xkd7Dov1fsy00KZ7If+opMgIWHaw/UnC5jwzILTAubBuFopV+SNJnLZ1EMMPsINpJF7eecj0/OoC+bEu/5Oxk8BeFO1UOoXZ4aOlzzDU6TpXP/0ULyoRtskc2UpXETRH2rapam41tbDX0JuXlfrrOFkPrB9O4jNMrm+6e9ldorGkpzKHKzZvOjp2WykMjiPzFk7GIordK96jmctwiEWG2N0HayLGsgdyT8YrhHatCZk51HZ+Jhk05zrmfPQqa+MhskIpcPfplei/sUcMD3TCN8Qw5mhyZPTbC6m/a6E3+wqw++Wos4ZMwcp71BiChdcIb/XhjG6oy/JHYV3Bth4ZuyRN1vnVzm4VDmdb5b2c= 50 | 51 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ashley Sommer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sanic-Dispatcher 2 | ### A Dispatcher extension for Sanic that also acts as a Sanic-to-WSGI adapter 3 | 4 | Allows you to do this: *(seriously)* 5 | ```python 6 | from sanic import Sanic, response 7 | from sanic_dispatcher import SanicDispatcherMiddlewareController 8 | from flask import Flask, make_response, current_app as flask_app 9 | 10 | app = Sanic(__name__) 11 | 12 | dispatcher = SanicDispatcherMiddlewareController(app) 13 | 14 | child_sanic_app = Sanic("MyChildSanicApp") 15 | 16 | child_flask_app = Flask("MyChildFlaskApp") 17 | 18 | @app.middleware("response") 19 | async def modify_response(request, response): 20 | response.body = response.body + b"\nModified by Sanic Response middleware!" 21 | response.headers['Content-Length'] = len(response.body) 22 | return response 23 | 24 | @app.route("/") 25 | async def index(request): 26 | return response.text("Hello World from {}".format(request.app.name)) 27 | 28 | @child_sanic_app.route("/") 29 | async def index(request): 30 | return response.text("Hello World from {}".format(request.app.name)) 31 | 32 | @child_flask_app.route("/") 33 | def index(): 34 | app = flask_app 35 | return make_response("Hello World from {}".format(app.import_name)) 36 | 37 | dispatcher.register_sanic_application(child_sanic_app, '/sanicchild', apply_middleware=True) 38 | dispatcher.register_wsgi_application(child_flask_app.wsgi_app, '/flaskchild', apply_middleware=True) 39 | 40 | if __name__ == "__main__": 41 | app.run(port=8001, debug=True) 42 | ``` 43 | 44 | ## Installation 45 | 46 | pip install Sanic-Dispatcher 47 | 48 | ## How To Use 49 | 50 | First make a Sanic application the way you normally do: 51 | ```python 52 | from sanic import Sanic 53 | 54 | app = Sanic(__name__) # This creates a sanic app 55 | ``` 56 | `app` becomes your 'base' or 'parent' sanic app which will accommodate the Dispatcher extension 57 | 58 | Create a Dispatcher 59 | ```python 60 | from sanic_dispatcher import SanicDispatcherMiddlewareController 61 | 62 | dispatcher = SanicDispatcherMiddlewareController(app) 63 | ``` 64 | `dispatcher` is your new dispatcher controller. 65 | *Note: This takes a reference to `app` as its first parameter, but it does not consume `app`, nor does it return `app`.* 66 | 67 | **I want to dispatch another Sanic App** 68 | ```python 69 | app = Sanic(__name__) 70 | 71 | dispatcher = SanicDispatcherMiddlewareController(app) 72 | 73 | otherapp = Sanic("MyChildApp") 74 | 75 | dispatcher.register_sanic_application(otherapp, "/childprefix") 76 | 77 | @otherapp.route('/') 78 | async def index(request): 79 | return response.text("Hello World from Child App") 80 | ``` 81 | Browsing to url `/childprefix/` will invoke the `otherapp` App, and call the `/` route which displays "Hello World from Child App" 82 | 83 | **What if the other App is a Flask App?** 84 | ```python 85 | from flask import Flask, make_response 86 | 87 | app = Sanic(__name__) 88 | 89 | dispatcher = SanicDispatcherMiddlewareController(app) 90 | flaskapp = Flask("MyFlaskApp") 91 | 92 | # register the wsgi_app method from the flask app into the dispatcher 93 | dispatcher.register_wsgi_application(flaskapp.wsgi_app, "/flaskprefix") 94 | 95 | @flaskapp.route('/') 96 | def index(): 97 | return make_response("Hello World from Flask App") 98 | ``` 99 | Browsing to url `/flaskprefix/` will invoke the Flask App, and call the `/` route which displays "Hello World from Flask App" 100 | 101 | **What if the other App is a Django App?** 102 | ```python 103 | import my_django_app 104 | 105 | app = Sanic(__name__) 106 | 107 | dispatcher = SanicDispatcherMiddlewareController(app) 108 | # register the django wsgi application into the dispatcher 109 | dispatcher.register_wsgi_application(my_django_app.wsgi.application, 110 | "/djangoprefix") 111 | ``` 112 | Browsing to url `/djangoprefix/` will invoke the Django App. 113 | 114 | **Can I run a default application?** 115 | 116 | The Sanic App `app` you create at the start is also the default app. 117 | 118 | When you navigate to a URL that does not match a registered dispatch prefix, this Sanic app will handle the request itself as per normal. 119 | ```python 120 | app = Sanic(__name__) 121 | 122 | dispatcher = SanicDispatcherMiddlewareController(app) 123 | 124 | otherapp = Sanic("MyChildApp") 125 | 126 | dispatcher.register_sanic_application(otherapp, "/childprefix") 127 | 128 | @app.route('/') 129 | async def index(request): 130 | return response.text("Hello World from Default App") 131 | 132 | @otherapp.route('/') 133 | async def index(request): 134 | return response.text("Hello World from Child App") 135 | ``` 136 | Browsing to url `/` will *not* invoke any Dispatcher applications, so `app` will handle the request itself, resolving the `/` route which displays "Hello World from Default App" 137 | 138 | **I want to apply common middleware to the registered applications!** 139 | 140 | Easy! 141 | ```python 142 | import my_django_app 143 | from flask import Flask, make_response, current_app 144 | 145 | app = Sanic(__name__) 146 | 147 | dispatcher = SanicDispatcherMiddlewareController(app) 148 | 149 | child_sanic_app = Sanic("MyChildSanicApp") 150 | 151 | child_flask_app = Flask("MyChildFlaskApp") 152 | 153 | @app.middleware("request") 154 | async def modify_request(request): 155 | request.headers['Content-Type'] = "text/plain" 156 | 157 | @app.middleware("response") 158 | async def modify_response(request, response): 159 | response.body = response.body + b"\nModified by Sanic Response middleware!" 160 | response.headers['Content-Length'] = len(response.body) 161 | return response 162 | 163 | @app.route("/") 164 | async def index(request): 165 | return response.text("Hello World from {}".format(request.app.name)) 166 | 167 | @child_sanic_app.route("/") 168 | async def index(request): 169 | return response.text("Hello World from {}".format(request.app.name)) 170 | 171 | @child_flask_app.route("/") 172 | def index(): 173 | app = current_app 174 | return make_response("Hello World from {}".format(app.import_name)) 175 | 176 | dispatcher.register_sanic_application(child_sanic_app, 177 | '/childprefix', apply_middleware=True) 178 | dispatcher.register_wsgi_application(my_django_app.wsgi.application, 179 | '/djangoprefix', apply_middleware=True) 180 | dispatcher.register_wsgi_application(child_flask_app.wsgi_app, 181 | '/flaskprefix', apply_middleware=True) 182 | ``` 183 | The key here is passing `apply_middleware=True` to the relevant register application function. By default `apply_middleware` is set to `False` for all registered dispatcher applications. 184 | 185 | In this example the Sanic Request Middleware `modify_request` will be applied to ALL requests, including those handled by applications registered on the dispatcher. The request middleware will be applied to the `request` *before* it is passed to any registered applications. 186 | 187 | In this example the Sanic Response Middleware `modify_response` will be applied to ALL responses, including those which were generated by applications registered on the dispatcher. The response middleware will be applied to the `response` *after* it is processed by the registered application. 188 | -------------------------------------------------------------------------------- /examples/test1.py: -------------------------------------------------------------------------------- 1 | """ 2 | sanic_dispatcher 3 | ~~~~ 4 | 5 | :copyright: (c) 2017 by Ashley Sommer (based on DispatcherMiddleware in the Werkzeug Project). 6 | :license: MIT, see LICENSE for more details. 7 | """ 8 | from sanic import Sanic, response 9 | from sanic_dispatcher import SanicDispatcherMiddlewareController 10 | from flask import Flask, make_response, current_app as flask_app 11 | from sanic_cors import CORS 12 | 13 | app = Sanic(__name__) 14 | dispatcher = SanicDispatcherMiddlewareController(app) 15 | child_sanic_app = Sanic("MyChildSanicApp") 16 | _ = CORS(child_sanic_app) 17 | child_sanic_app_hosted = Sanic("MyChildHostedSanicApp") 18 | child_flask_app = Flask("MyChildFlaskApp") 19 | 20 | 21 | @app.middleware("response") 22 | async def modify_response(request, response): 23 | response.body = response.body + b"\nModified by Sanic Response middleware!" 24 | response.headers['Content-Length'] = len(response.body) 25 | return response 26 | 27 | 28 | @app.listener('before_server_start') 29 | async def b0(app, loop): 30 | print("Parent app {} starting".format(app.name)) 31 | return 32 | 33 | 34 | @app.route("/") 35 | async def index1(request): 36 | return response.text("Hello World from {}".format(request.app.name)) 37 | 38 | 39 | @child_sanic_app.route("/", methods=['GET', 'OPTIONS']) 40 | async def index2(request): 41 | return response.text("Hello World from {}.".format(request.app.name)) 42 | 43 | 44 | @child_sanic_app.listener('before_server_start') 45 | async def b1(app, loop): 46 | print("Child app {} Starting".format(app.name)) 47 | return 48 | 49 | 50 | @child_sanic_app_hosted.route("/") 51 | async def index3(request): 52 | return response.text("Hello World from {}".format(request.app.name)) 53 | 54 | 55 | @child_flask_app.route("/") 56 | def index4(): 57 | app = flask_app 58 | return make_response("Hello World from {}".format(app.import_name)) 59 | 60 | 61 | dispatcher.register_sanic_application(child_sanic_app, '/sanicchild', apply_middleware=True) 62 | dispatcher.register_sanic_application(child_sanic_app_hosted, '/sanicchild', host='example.com', apply_middleware=True) 63 | dispatcher.register_wsgi_application(child_flask_app.wsgi_app, '/flaskchild', apply_middleware=True) 64 | #dispatcher.unregister_application(child_sanic_app) 65 | 66 | test_url = dispatcher.url_for("index1") 67 | 68 | if __name__ == "__main__": 69 | app.run(port=8001, debug=True, auto_reload=False) 70 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-asyncio 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sanic>=0.7.0 2 | -------------------------------------------------------------------------------- /sanic_dispatcher/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | sanic_dispatcher 4 | ~~~~ 5 | 6 | :copyright: (c) 2017 by Ashley Sommer (based on DispatcherMiddleware in the Werkzeug Project). 7 | :license: MIT, see LICENSE for more details. 8 | """ 9 | from .extension import SanicDispatcherMiddleware, SanicDispatcherMiddlewareController 10 | from .version import __version__ 11 | 12 | __all__ = ['SanicDispatcherMiddleware', 'SanicDispatcherMiddlewareController'] 13 | 14 | -------------------------------------------------------------------------------- /sanic_dispatcher/extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | sanic_dispatcher 4 | ~~~~ 5 | 6 | :copyright: (c) 2017 by Ashley Sommer (based on DispatcherMiddleware in the Werkzeug Project). 7 | :license: MIT, see LICENSE for more details. 8 | """ 9 | from collections import defaultdict 10 | from inspect import isawaitable 11 | from io import BytesIO 12 | from warnings import warn 13 | try: 14 | from setuptools.extern import packaging 15 | except ImportError: 16 | from pkg_resources.extern import packaging 17 | 18 | from sanic import Sanic, __version__ as sanic_version 19 | from sanic.exceptions import URLBuildError 20 | from sanic.response import HTTPResponse, BaseHTTPResponse 21 | from sanic.server import HttpProtocol 22 | from sanic.websocket import WebSocketProtocol 23 | 24 | SANIC_VERSION = packaging.version.parse(sanic_version) 25 | SANIC_0_7_0 = packaging.version.parse('0.7.0') 26 | if SANIC_VERSION < SANIC_0_7_0: 27 | raise RuntimeError("Please use Sanic v0.7.0 or greater with this extension.") 28 | SANIC_19_03_0 = packaging.version.parse('19.3.0') 29 | SANIC_18_12_0 = packaging.version.parse('18.12.0') 30 | SANIC_21_03_0 = packaging.version.parse('21.03.0') 31 | IS_19_03 = SANIC_VERSION >= SANIC_19_03_0 32 | IS_18_12 = SANIC_VERSION >= SANIC_18_12_0 33 | IS_21_03 = SANIC_VERSION >= SANIC_21_03_0 34 | if IS_19_03: 35 | from sanic.request import RequestParameters 36 | from sanic.log import error_logger 37 | else: 38 | import logging 39 | error_logger = logging.getLogger("sanic.error") 40 | if IS_21_03: 41 | from sanic.compat import CancelledErrors 42 | 43 | class WsgiApplication(object): 44 | __slots__ = ['app', 'apply_middleware'] 45 | 46 | def __init__(self, app, apply_middleware=False): 47 | self.app = app 48 | self.apply_middleware = apply_middleware 49 | 50 | 51 | class SanicApplication(object): 52 | __slots__ = ['app', 'server_settings', 'apply_middleware'] 53 | 54 | def __init__(self, app, apply_middleware=False): 55 | self.app = app 56 | self.server_settings = {} 57 | self.apply_middleware = apply_middleware 58 | 59 | 60 | class SanicCompatURL(object): 61 | """ 62 | This class exists because the sanic native URL type is private (a non-exposed C module) 63 | and all of its components are read-only. We need to modify the URL path in the dispatcher 64 | so we build a feature-compatible writable class of our own to use instead. 65 | """ 66 | __slots__ = ('schema', 'host', 'port', 'path', 'query', 'fragment', 'userinfo') 67 | 68 | def __init__(self, schema, host, port, path, query, fragment, userinfo): 69 | self.schema = schema 70 | self.host = host 71 | self.port = port 72 | self.path = path 73 | self.query = query 74 | self.fragment = fragment 75 | self.userinfo = userinfo 76 | 77 | class SanicComatRequest(object): 78 | __slots__ = ("orig_req", "parent_app") 79 | def __init__(self, orig_req, parent_app): 80 | self.orig_req = orig_req 81 | self.parent_app = parent_app 82 | 83 | def __getattr__(self, item): 84 | if item in SanicComatRequest.__slots__: 85 | return object.__getattribute__(self, item) 86 | return getattr(self.orig_req, item) 87 | 88 | def __setattr__(self, key, val): 89 | if key in SanicComatRequest.__slots__: 90 | return object.__setattr__(self, key, val) 91 | return setattr(self.orig_req, key, val) 92 | 93 | async def respond(self, response=None, *args, status=200, headers=None, content_type=None): 94 | # From Sanic 21.03 95 | # This logic of determining which response to use is subject to change 96 | if response is None: 97 | response = (self.stream and self.stream.response) or HTTPResponse( 98 | status=status, 99 | headers=headers, 100 | content_type=content_type, 101 | ) 102 | # Connect the response 103 | if isinstance(response, BaseHTTPResponse) and self.stream: 104 | response = self.stream.respond(response) 105 | # Run child's response middleware 106 | try: 107 | response = await self.app._run_response_middleware( 108 | self, response, request_name=self.name 109 | ) 110 | except CancelledErrors: 111 | raise 112 | except Exception: 113 | error_logger.exception( 114 | "Exception occurred in one of response middleware handlers" 115 | ) 116 | parent_app = self.parent_app 117 | if parent_app.response_middleware: 118 | self.orig_req.app = parent_app 119 | for _middleware in parent_app.response_middleware: 120 | _response = _middleware(self, response) 121 | if isawaitable(_response): 122 | _response = await _response 123 | if _response: 124 | response = _response 125 | if isinstance(response, BaseHTTPResponse): 126 | response = self.stream.respond(response) 127 | return response 128 | 129 | 130 | 131 | class SanicDispatcherMiddleware(object): 132 | """ 133 | A multi-application dispatcher, and also acts as a sanic-to-wsgiApp adapter. 134 | Based on the DispatcherMiddleware class in werkzeug. 135 | """ 136 | 137 | __slots__ = ['parent_app', 'parent_handle_request', 'mounts', 'hosts'] 138 | 139 | use_wsgi_threads = True 140 | 141 | def __init__(self, parent_app, parent_handle_request, mounts=None, hosts=None): 142 | self.parent_app = parent_app 143 | self.parent_handle_request = parent_handle_request 144 | self.mounts = mounts or {} 145 | self.hosts = frozenset(hosts) if hosts else frozenset() 146 | 147 | @staticmethod 148 | def _call_wsgi(script_name, path_info, request, wsgi_app, response_callback): 149 | http_response = None 150 | body_bytes = bytearray() 151 | 152 | def _start_response(status, headers, *args, **kwargs): 153 | """The start_response callback as required by the wsgi spec. This sets up a response including the 154 | status code and the headers, but doesn't write a body.""" 155 | nonlocal http_response 156 | nonlocal body_bytes 157 | if isinstance(status, int): 158 | code = status 159 | elif isinstance(status, str): 160 | code = int(status.split(" ")[0]) 161 | else: 162 | raise RuntimeError("status cannot be turned into a code.") 163 | sanic_headers = dict(headers) 164 | response_constructor_args = {'status': code, 'headers': sanic_headers} 165 | if 'content_type' in kwargs: 166 | response_constructor_args['content_type'] = kwargs['content_type'] 167 | elif 'Content-Type' in sanic_headers: 168 | response_constructor_args['content_type'] = str(sanic_headers['Content-Type']).split(";")[0].strip() 169 | http_response = HTTPResponse(**response_constructor_args) 170 | 171 | def _write_body(body_data): 172 | """This doesn't seem to be used, but it is part of the wsgi spec, so need to have it.""" 173 | nonlocal body_bytes 174 | nonlocal http_response 175 | if isinstance(body_data, bytes): 176 | pass 177 | else: 178 | try: 179 | # Try to encode it regularly 180 | body_data = body_data.encode() 181 | except AttributeError: 182 | # Convert it to a str if you can't 183 | body_data = str(body_data).encode() 184 | body_bytes.extend(body_data) 185 | return _write_body 186 | 187 | environ = {} 188 | original_script_name = environ.get('SCRIPT_NAME', '') 189 | environ['SCRIPT_NAME'] = original_script_name + script_name 190 | environ['PATH_INFO'] = path_info 191 | if request._parsed_url and request._parsed_url.host is not None: 192 | host = request._parsed_url.host.decode('utf-8') 193 | elif 'host' in request.headers: 194 | host = request.headers['host'] 195 | else: 196 | host = 'localhost:80' 197 | if 'content-type' in request.headers: 198 | content_type = request.headers['content-type'] 199 | else: 200 | content_type = 'text/plain' 201 | environ['CONTENT_TYPE'] = content_type 202 | if 'content-length' in request.headers: 203 | content_length = request.headers['content-length'] 204 | environ['CONTENT_LENGTH'] = content_length 205 | 206 | split_host = host.split(':', 1) 207 | host_has_port = len(split_host) > 1 208 | server_name = split_host[0] 209 | if request._parsed_url and request._parsed_url.port is not None: 210 | server_port = request._parsed_url.port.decode('ascii') 211 | elif host_has_port: 212 | server_port = split_host[1] 213 | else: 214 | server_port = '80' # TODO: Find a better way of determining the port number when not provided 215 | if (not host_has_port) and (server_port != '80'): 216 | host = ":".join((host, server_port)) 217 | environ['SERVER_PORT'] = server_port 218 | environ['SERVER_NAME'] = server_name 219 | environ['SERVER_PROTOCOL'] = 'HTTP/1.1' if request.version == "1.1" else 'HTTP/1.0' 220 | environ['HTTP_HOST'] = host 221 | environ['QUERY_STRING'] = request.query_string or '' 222 | environ['REQUEST_METHOD'] = request.method 223 | environ['wsgi.url_scheme'] = 'http' # todo: detect http vs https 224 | environ['wsgi.input'] = BytesIO(request.body) if request.body is not None and len(request.body) > 0\ 225 | else BytesIO(b'') 226 | try: 227 | wsgi_return = wsgi_app(environ, _start_response) 228 | except Exception as e: 229 | error_logger.exception(e) 230 | raise e 231 | if http_response is None: 232 | http_response = HTTPResponse("WSGI call error.", 500) 233 | else: 234 | for body_part in wsgi_return: 235 | if body_part is not None: 236 | if isinstance(body_part, bytes): 237 | pass 238 | else: 239 | try: 240 | # Try to encode it regularly 241 | body_part = body_part.encode() 242 | except AttributeError: 243 | # Convert it to a str if you can't 244 | body_part = str(body_part).encode() 245 | body_bytes.extend(body_part) 246 | http_response.body = bytes(body_bytes) 247 | if response_callback: 248 | return response_callback(http_response) 249 | return http_response 250 | 251 | if IS_21_03: 252 | async def call_wsgi_app(self, script_name, path_info, request, wsgi_app): 253 | 254 | if self.use_wsgi_threads: 255 | return await self.parent_app.loop.run_in_executor(None, self._call_wsgi, script_name, path_info, request, wsgi_app, False) 256 | else: 257 | return self._call_wsgi(script_name, path_info, request, wsgi_app, False) 258 | else: 259 | async def call_wsgi_app(self, script_name, path_info, request, wsgi_app, response_callback): 260 | if self.use_wsgi_threads: 261 | return await self.parent_app.loop.run_in_executor( 262 | None, self._call_wsgi, script_name, path_info, request, 263 | wsgi_app, response_callback) 264 | else: 265 | return self._call_wsgi(script_name, path_info, request, wsgi_app, response_callback) 266 | @staticmethod 267 | def get_request_scheme(request): 268 | try: 269 | if request.headers.get('upgrade') == 'websocket': 270 | scheme = b'ws' 271 | elif request.transport.get_extra_info('sslcontext'): 272 | scheme = b'https' 273 | else: 274 | scheme = b'http' 275 | except (AttributeError, KeyError): 276 | scheme = b'http' 277 | return scheme 278 | 279 | def _get_application_by_route(self, request, use_host=False): 280 | host = request.headers.get('Host', '') 281 | scheme = self.get_request_scheme(request) 282 | path = request._parsed_url.path 283 | port = request._parsed_url.port 284 | query_string = request._parsed_url.query 285 | fragment = request._parsed_url.fragment 286 | userinfo = request._parsed_url.userinfo 287 | script = path 288 | if ':' in host and port is None: 289 | (host, port) = host.split(':', 1)[0:2] 290 | port = port.encode('ascii') 291 | host_bytes = host.encode('utf-8') 292 | 293 | if use_host: 294 | script = b'%s%s' % (host_bytes, script) 295 | path_info = b'' 296 | while b'/' in script: 297 | script_str = script.decode('utf-8') 298 | try: 299 | application = self.mounts[script_str] 300 | break 301 | except KeyError: 302 | pass 303 | script, last_item = script.rsplit(b'/', 1) 304 | path_info = b'/%s%s' % (last_item, path_info) 305 | else: 306 | script_str = script.decode('utf-8') 307 | application = self.mounts.get(script_str, None) 308 | if application is not None: 309 | request._parsed_url = SanicCompatURL(scheme, host_bytes, port, 310 | path_info, query_string, fragment, userinfo) 311 | if IS_19_03: 312 | # To trigger re-parse args 313 | request.parsed_args = defaultdict(RequestParameters) 314 | else: 315 | request.parsed_args = None # To trigger re-parse args 316 | path = request.path 317 | return application, script_str, path 318 | 319 | if IS_21_03: 320 | async def __call__(self, request): 321 | # Assume at this point that we have no app. So we cannot know if we are on Websocket or not. 322 | if self.hosts and len(self.hosts) > 0: 323 | application, script, path = self._get_application_by_route(request, use_host=True) 324 | if application is None: 325 | application, script, path = self._get_application_by_route(request, use_host=False) 326 | else: 327 | application, script, path = self._get_application_by_route(request) 328 | if application is None: # no child matches, call the parent 329 | return await self.parent_handle_request(request) 330 | return await self._call_2103(request, application, script, path) 331 | else: 332 | async def __call__(self, request, write_callback, stream_callback): 333 | # Assume at this point that we have no app. So we cannot know if we are on Websocket or not. 334 | if self.hosts and len(self.hosts) > 0: 335 | application, script, path = self._get_application_by_route(request, use_host=True) 336 | if application is None: 337 | application, script, path = self._get_application_by_route(request, use_host=False) 338 | else: 339 | application, script, path = self._get_application_by_route(request) 340 | if application is None: # no child matches, call the parent 341 | return await self.parent_handle_request(request, write_callback, stream_callback) 342 | return await self._call_old(request, application, script, path, write_callback, stream_callback) 343 | 344 | async def _call_old(self, request, application, script, path, write_callback, stream_callback): 345 | real_write_callback = write_callback 346 | real_stream_callback = stream_callback 347 | response = False 348 | streaming_response = False 349 | def _write_callback(child_response): 350 | nonlocal response 351 | response = child_response 352 | 353 | def _stream_callback(child_stream): 354 | nonlocal streaming_response 355 | streaming_response = child_stream 356 | 357 | replaced_write_callback = _write_callback 358 | replaced_stream_callback = _stream_callback 359 | parent_app = self.parent_app 360 | if application.apply_middleware and parent_app.request_middleware: 361 | request.app = parent_app 362 | for middleware in parent_app.request_middleware: 363 | response = middleware(request) 364 | if isawaitable(response): 365 | response = await response 366 | if response: 367 | break 368 | child_app = application.app 369 | if not response and not streaming_response: 370 | if isinstance(application, WsgiApplication): # child is wsgi_app 371 | await self.call_wsgi_app(script, path, request, 372 | child_app, replaced_write_callback) 373 | else: # must be a sanic application 374 | request.app = child_app 375 | await child_app.handle_request(request, replaced_write_callback, replaced_stream_callback) 376 | 377 | if application.apply_middleware and parent_app.response_middleware: 378 | request.app = parent_app 379 | for _middleware in parent_app.response_middleware: 380 | _response = _middleware(request, response) 381 | if isawaitable(_response): 382 | _response = await _response 383 | if _response: 384 | response = _response 385 | break 386 | 387 | while isawaitable(response): 388 | response = await response 389 | if streaming_response: 390 | return real_stream_callback(streaming_response) 391 | return real_write_callback(response) 392 | 393 | 394 | async def _call_2103(self, request, application, script, path): 395 | 396 | our_response = False 397 | streaming_response = False 398 | 399 | if request.stream.request_body: # type: ignore 400 | # Non-streaming handler: preload body 401 | await request.receive_body() 402 | 403 | parent_app = self.parent_app 404 | if application.apply_middleware and parent_app.request_middleware: 405 | request.app = parent_app 406 | for middleware in parent_app.request_middleware: 407 | our_response = middleware(request) 408 | if isawaitable(our_response): 409 | our_response = await our_response 410 | if our_response: 411 | break 412 | child_app = application.app 413 | if application.apply_middleware: 414 | request = SanicComatRequest(request, parent_app) 415 | if not our_response and not streaming_response: 416 | if isinstance(application, WsgiApplication): # child is wsgi_app 417 | our_response = await self.call_wsgi_app(script, path, request, child_app) 418 | else: # must be a sanic application 419 | request.app = child_app 420 | return await child_app.handle_request(request) 421 | if our_response is not None: 422 | try: 423 | our_response = await request.respond(our_response) 424 | except BaseException: 425 | # Skip response middleware 426 | if request.stream: 427 | request.stream.respond(our_response) 428 | await our_response.send(end_stream=True) 429 | raise 430 | else: 431 | if request.stream: 432 | our_response = request.stream.response 433 | if isinstance(our_response, BaseHTTPResponse): 434 | await our_response.send(end_stream=True) 435 | return our_response 436 | 437 | 438 | class SanicDispatcherMiddlewareController(object): 439 | __slots__ = ['parent_app', 'parent_handle_request', 'parent_url_for', 'applications', 'url_prefix', 440 | 'filter_host', 'hosts', 'started'] 441 | 442 | def __init__(self, app, url_prefix=None, host=None): 443 | """ 444 | :param Sanic app: 445 | :param url_prefix: 446 | """ 447 | self.parent_app = app 448 | self.applications = {} 449 | self.url_prefix = None if url_prefix is None else str(url_prefix).rstrip('/') 450 | self.hosts = set() 451 | if host: 452 | self.filter_host = host 453 | self.hosts.add(host) 454 | else: 455 | self.filter_host = None 456 | self.started = False 457 | self.parent_app.register_listener(self._before_server_start_listener, 'before_server_start') 458 | self.parent_app.register_listener(self._after_server_start_listener, 'after_server_start') 459 | self.parent_app.register_listener(self._before_server_stop_listener, 'before_server_stop') 460 | self.parent_app.register_listener(self._after_server_stop_listener, 'after_server_stop') 461 | # Woo, monkey-patch! 462 | self.parent_handle_request = app.handle_request 463 | self.parent_url_for = app.url_for 464 | if IS_21_03: 465 | self.parent_app.handle_request = self.handle_request_2103 466 | else: 467 | self.parent_app.handle_request = self.handle_request 468 | self.parent_app.url_for = self.patched_url_for 469 | 470 | async def _before_server_start_listener(self, app, loop): 471 | if self.started is True: 472 | raise RuntimeError("Cannot start a sanic parent application more than once.") 473 | has_ws = False 474 | for route, child_app in self.applications.items(): 475 | if isinstance(child_app, SanicApplication): 476 | s_app = child_app.app 477 | is_ws = getattr(s_app, 'websocket_enabled', False) 478 | protocol = WebSocketProtocol if is_ws else HttpProtocol 479 | server_settings = s_app._helper(host=None, port=None, loop=loop, 480 | protocol=protocol, run_async=False) 481 | child_app.server_settings = server_settings 482 | await s_app.trigger_events( 483 | server_settings.get("before_start", []), 484 | server_settings.get("loop"), 485 | ) 486 | has_ws = has_ws or is_ws 487 | if not getattr(app, 'websocket_enabled', False) and has_ws: 488 | raise RuntimeError( 489 | "Found child apps with Websockets enabled, but parent app is not Websockets enabled.\n" 490 | "Add parent_app.enable_websocket() before starting the app.") 491 | 492 | async def _after_server_start_listener(self, app, loop): 493 | is_asgi = getattr(app, 'asgi', False) 494 | if is_asgi: 495 | error_logger.warning("Sanic-Dispatcher has not been tested on ASGI apps. It may not work correctly.") 496 | 497 | self.started = True 498 | 499 | for route, child_app in self.applications.items(): 500 | if isinstance(child_app, SanicApplication): 501 | server_settings = child_app.server_settings 502 | await child_app.app.trigger_events( 503 | server_settings.get("after_start", []), 504 | server_settings.get("loop"), 505 | ) 506 | 507 | async def _before_server_stop_listener(self, app, loop): 508 | for route, child_app in self.applications.items(): 509 | if isinstance(child_app, SanicApplication): 510 | server_settings = child_app.server_settings 511 | await child_app.app.trigger_events( 512 | server_settings.get("before_stop", []), 513 | server_settings.get("loop"), 514 | ) 515 | 516 | async def _after_server_stop_listener(self, app, loop): 517 | for route, child_app in self.applications.items(): 518 | if isinstance(child_app, SanicApplication): 519 | server_settings = child_app.server_settings 520 | await child_app.app.trigger_events( 521 | server_settings.get("after_stop", []), 522 | server_settings.get("loop"), 523 | ) 524 | 525 | def _determine_uri(self, url_prefix, host=None): 526 | uri = '' 527 | if self.url_prefix is not None: 528 | uri = self.url_prefix 529 | if host is not None: 530 | uri = str(host) + uri 531 | self.hosts.add(host) 532 | elif self.filter_host is not None: 533 | uri = str(self.filter_host) + uri 534 | uri += url_prefix 535 | return uri 536 | 537 | def register_app(self, app, url_prefix, host=None, apply_middleware=False): 538 | if isinstance(app, Sanic): 539 | self.register_sanic_application(app, url_prefix, host=host, 540 | apply_middleware=apply_middleware) 541 | else: 542 | self.register_wsgi_application(app, url_prefix, host=host, 543 | apply_middleware=apply_middleware) 544 | 545 | def register_sanic_application(self, application, url_prefix, host=None, apply_middleware=False): 546 | """ 547 | :param Sanic application: 548 | :param url_prefix: 549 | :param host: 550 | :param apply_middleware: 551 | :return: 552 | """ 553 | if self.started is True: 554 | raise warn("Registering an application when the server is already started may work," 555 | "but is not supported.") 556 | assert isinstance(application, Sanic),\ 557 | "Pass only instances of Sanic to register_sanic_application." 558 | if str(url_prefix).endswith('/'): 559 | url_prefix = url_prefix[:-1] 560 | if host is not None and isinstance(host, (list, set)): 561 | for _host in host: 562 | self.register_sanic_application(application, url_prefix, host=_host, 563 | apply_middleware=apply_middleware) 564 | return 565 | 566 | registered_service_url = self._determine_uri(url_prefix, host) 567 | self.applications[registered_service_url] = SanicApplication(application, apply_middleware) 568 | self._update_request_handler() 569 | 570 | def register_wsgi_application(self, application, url_prefix, host=None, apply_middleware=False): 571 | """ 572 | :param application: 573 | :param url_prefix: 574 | :param apply_middleware: 575 | :return: 576 | """ 577 | if self.started is True: 578 | raise RuntimeError("Cannot register an application when the server is already started.") 579 | if str(url_prefix).endswith('/'): 580 | url_prefix = url_prefix[:-1] 581 | if host is not None and isinstance(host, (list, set)): 582 | for _host in host: 583 | self.register_wsgi_application(application, url_prefix, host=_host, 584 | apply_middleware=apply_middleware) 585 | return 586 | 587 | registered_service_url = self._determine_uri(url_prefix, host) 588 | self.applications[registered_service_url] = WsgiApplication(application, apply_middleware) 589 | self._update_request_handler() 590 | 591 | def unregister_application(self, application, all_matches=False): 592 | if isinstance(application, (SanicApplication, WsgiApplication)): 593 | application = application.app 594 | urls_to_unregister = [] 595 | for url, reg_application in self.applications.items(): 596 | if reg_application.app == application: 597 | urls_to_unregister.append(url) 598 | if not all_matches: 599 | break 600 | for url in urls_to_unregister: 601 | del self.applications[url] 602 | self._update_request_handler() 603 | 604 | def unregister_prefix(self, url_prefix, host=None): 605 | if str(url_prefix).endswith('/'): 606 | url_prefix = url_prefix[:-1] 607 | if host is not None and isinstance(host, (list, set)): 608 | for _host in host: 609 | self.unregister_prefix(url_prefix, host=_host) 610 | return 611 | registered_service_url = self._determine_uri(url_prefix, host) 612 | try: 613 | del self.applications[registered_service_url] 614 | except KeyError: 615 | pass 616 | self._update_request_handler() 617 | 618 | def _update_request_handler(self): 619 | """ 620 | Rebuilds the SanicDispatcherMiddleware every time a new application is registered 621 | :return: 622 | """ 623 | dispatcher = SanicDispatcherMiddleware(self.parent_app, self.parent_handle_request, self.applications, 624 | self.hosts) 625 | self.parent_app.handle_request = dispatcher 626 | 627 | async def handle_request(self, request, write_callback, stream_callback): 628 | """ 629 | This is only called as a backup handler if _update_request_handler was not yet called. 630 | :param request: 631 | :param write_callback: 632 | :param stream_callback: 633 | :return: 634 | """ 635 | dispatcher = SanicDispatcherMiddleware(self.parent_app, self.parent_handle_request, self.applications, 636 | self.hosts) 637 | self.parent_app.handle_request = dispatcher # save it for next time 638 | retval = dispatcher(request, write_callback, stream_callback) 639 | if isawaitable(retval): 640 | retval = await retval 641 | return retval 642 | 643 | async def handle_request_2103(self, request): 644 | """ 645 | This is only called as a backup handler if _update_request_handler was not yet called. 646 | :param request: 647 | :return: 648 | """ 649 | dispatcher = SanicDispatcherMiddleware(self.parent_app, self.parent_handle_request, self.applications, 650 | self.hosts) 651 | self.parent_app.handle_request = dispatcher # save it for next time 652 | _ = await dispatcher(request) 653 | # This 2103 handler doesn't return anything 654 | 655 | def _dispatcher_url_for(self, view_name, **kwargs): 656 | """ 657 | Checks all of the registered applications in the dispatcher for `url_for()` 658 | :param str view_name: 659 | :param kwargs: 660 | :return: 661 | """ 662 | for url_prefix, reg_application in self.applications.items(): 663 | app = reg_application.app 664 | try: 665 | _url_for = getattr(app, 'url_for', None) 666 | if _url_for is None: 667 | continue 668 | try: 669 | _url = _url_for(view_name, **kwargs) 670 | except URLBuildError: 671 | continue 672 | if _url is not None: 673 | return ''.join((url_prefix, str(_url))) 674 | except (AssertionError, KeyError): 675 | continue 676 | return None 677 | 678 | def patched_url_for(self, view_name, **kwargs): 679 | """ 680 | When `url_for()` is called on the parent app, this gets called instead. 681 | :param str view_name: 682 | :param kwargs: 683 | :return: 684 | """ 685 | try: 686 | url = self.parent_url_for(view_name, **kwargs) 687 | except URLBuildError: 688 | url = None 689 | if url is None: 690 | try: 691 | url = self._dispatcher_url_for(view_name, **kwargs) 692 | except URLBuildError: 693 | url = None 694 | if url is None: 695 | raise URLBuildError("Url Not found in the Parent App, nor the Dispatcher routes") 696 | 697 | return url 698 | 699 | def url_for(self, view_name, **kwargs): 700 | """ 701 | When `url_for()` is called on the dispatcher app, this gets called instead. 702 | :param str view_name: 703 | :param kwargs: 704 | :return: 705 | """ 706 | try: 707 | url = self._dispatcher_url_for(view_name, **kwargs) 708 | except URLBuildError: 709 | url = None 710 | if url is None: 711 | try: 712 | url = self.parent_url_for(view_name, **kwargs) 713 | except URLBuildError: 714 | url = None 715 | if url is None: 716 | raise URLBuildError("Url Not found in the Dispatcher routes, nor the Parent App") 717 | return url 718 | -------------------------------------------------------------------------------- /sanic_dispatcher/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | __version__ = '0.8.0.0' 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = true 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | setup 4 | ~~~~ 5 | A Dispatcher extension for Sanic which also acts as a Sanic-to-WSGI adapter 6 | 7 | :copyright: (c) 2017-2021 by Ashley Sommer (based on DispatcherMiddleware in Workzeug). 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from setuptools import setup 12 | from os.path import join, dirname 13 | 14 | with open(join(dirname(__file__), 'sanic_dispatcher/version.py'), 'r', 15 | encoding='latin-1') as f: 16 | exec(f.read()) 17 | 18 | with open(join(dirname(__file__), 'requirements.txt'), 'r') as f: 19 | install_requires = f.read().split("\n") 20 | 21 | setup( 22 | name='Sanic-Dispatcher', 23 | version=__version__, 24 | url='https://github.com/ashleysommer/sanic-dispatcher', 25 | license='MIT', 26 | author='Ashley Sommer', 27 | author_email='ashleysommer@gmail.com', 28 | description="Multi-application dispatcher based on DispatcherMiddleware from the Werkzeug Project.", 29 | long_description=open('README.md').read(), 30 | long_description_content_type="text/markdown", 31 | packages=['sanic_dispatcher'], 32 | zip_safe=False, 33 | include_package_data=True, 34 | platforms='any', 35 | install_requires=install_requires, 36 | tests_require=[ 37 | 'nose' 38 | ], 39 | test_suite='nose.collector', 40 | classifiers=[ 41 | 'Environment :: Web Environment', 42 | 'Intended Audience :: Developers', 43 | 'License :: OSI Approved :: MIT License', 44 | 'Operating System :: OS Independent', 45 | 'Programming Language :: Python', 46 | 'Programming Language :: Python :: 3', 47 | 'Programming Language :: Python :: 3 :: Only', 48 | 'Programming Language :: Python :: Implementation :: CPython', 49 | 'Programming Language :: Python :: Implementation :: PyPy', 50 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 51 | 'Topic :: Software Development :: Libraries :: Python Modules' 52 | ] 53 | ) 54 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_asyncio 3 | pytestmark = pytest.mark.asyncio 4 | from sanic import Sanic 5 | from sanic import __version__ as sanic_version 6 | from sanic_dispatcher import SanicDispatcherMiddlewareController, SanicDispatcherMiddleware 7 | 8 | try: 9 | from setuptools.extern import packaging 10 | except ImportError: 11 | from pkg_resources.extern import packaging 12 | 13 | SANIC_VERSION = packaging.version.parse(sanic_version) 14 | SANIC_0_7_0 = packaging.version.parse('0.7.0') 15 | if SANIC_VERSION < SANIC_0_7_0: 16 | raise RuntimeError("Please use Sanic v0.7.0 or greater with this extension.") 17 | SANIC_19_03_0 = packaging.version.parse('19.3.0') 18 | SANIC_18_12_0 = packaging.version.parse('18.12.0') 19 | SANIC_21_03_0 = packaging.version.parse('21.03.0') 20 | IS_19_03 = SANIC_VERSION >= SANIC_19_03_0 21 | IS_18_12 = SANIC_VERSION >= SANIC_18_12_0 22 | IS_21_03 = SANIC_VERSION >= SANIC_21_03_0 23 | 24 | if IS_21_03: 25 | from sanic_testing import TestManager 26 | 27 | def app_with_name(name): 28 | s = Sanic(name) 29 | if IS_21_03: 30 | manager = TestManager(s) 31 | return s 32 | 33 | @pytest.fixture 34 | def app(request): 35 | a = app_with_name(request.node.name) 36 | return a 37 | 38 | @pytest.fixture 39 | def dispatcher(request): 40 | a = app_with_name(request.node.name) 41 | return SanicDispatcherMiddlewareController(a) 42 | -------------------------------------------------------------------------------- /test/test_basic.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic import response 3 | import pytest 4 | 5 | from sanic_dispatcher import SanicDispatcherMiddlewareController, SanicDispatcherMiddleware 6 | # 7 | # app = Sanic(__name__) 8 | # dispatcher = SanicDispatcherMiddlewareController(app) 9 | # child_sanic_app = Sanic("MyChildSanicApp") 10 | # _ = CORS(child_sanic_app) 11 | # child_sanic_app_hosted = Sanic("MyChildHostedSanicApp") 12 | # child_flask_app = Flask("MyChildFlaskApp") 13 | 14 | 15 | # @app.middleware("response") 16 | # async def modify_response(request, response): 17 | # response.body = response.body + b"\nModified by Sanic Response middleware!" 18 | # response.headers['Content-Length'] = len(response.body) 19 | # return response 20 | # 21 | # 22 | # @app.listener('before_server_start') 23 | # async def b0(app, loop): 24 | # print("Parent app {} starting".format(app.name)) 25 | # return 26 | # 27 | # 28 | # @app.route("/") 29 | # async def index1(request): 30 | # return response.text("Hello World from {}".format(request.app.name)) 31 | # 32 | # 33 | # @child_sanic_app.route("/", methods=['GET', 'OPTIONS']) 34 | # async def index2(request): 35 | # return response.text("Hello World from {}.".format(request.app.name)) 36 | # 37 | # 38 | # @child_sanic_app.listener('before_server_start') 39 | # async def b1(app, loop): 40 | # print("Child app {} Starting".format(app.name)) 41 | # return 42 | # 43 | # 44 | # @child_sanic_app_hosted.route("/") 45 | # async def index3(request): 46 | # return response.text("Hello World from {}".format(request.app.name)) 47 | # 48 | # 49 | # @child_flask_app.route("/") 50 | # def index4(): 51 | # app = flask_app 52 | # return make_response("Hello World from {}".format(app.import_name)) 53 | 54 | 55 | # dispatcher.register_sanic_application(child_sanic_app, '/sanicchild', apply_middleware=True) 56 | # dispatcher.register_sanic_application(child_sanic_app_hosted, '/sanicchild', host='example.com', apply_middleware=True) 57 | # dispatcher.register_wsgi_application(child_flask_app.wsgi_app, '/flaskchild', apply_middleware=True) 58 | # #dispatcher.unregister_application(child_sanic_app) 59 | # 60 | # test_url = dispatcher.url_for("index1") 61 | 62 | def test_basic_parent(dispatcher): 63 | @dispatcher.parent_app.route("/test", methods=['GET', 'OPTIONS']) 64 | async def index1(request): 65 | return response.text("Hello World from {}.".format(request.app.name)) 66 | tester = dispatcher.parent_app.test_client 67 | request, resp = tester.get("/test", gather_request=True) 68 | assert resp.status == 200 69 | 70 | def test_basic_child(dispatcher): 71 | child_sanic_app = Sanic("child1") 72 | @dispatcher.parent_app.route("/test", methods=['GET', 'OPTIONS']) 73 | async def index1(request): 74 | return response.text("Hello World from {}.".format(request.app.name)) 75 | @child_sanic_app.route("/test", methods=['GET', 'OPTIONS']) 76 | async def index2(request): 77 | return response.text("Hello World from {}.".format(request.app.name)) 78 | dispatcher.register_sanic_application(child_sanic_app, '/sanicchild', apply_middleware=True) 79 | tester = dispatcher.parent_app.test_client 80 | request, resp = tester.get("/sanicchild/test", gather_request=True) 81 | assert resp.status == 200 82 | assert "child1" in resp.text 83 | 84 | def test_child_with_mw(dispatcher): 85 | child_sanic_app = Sanic("child2") 86 | @dispatcher.parent_app.route("/test", methods=['GET', 'OPTIONS']) 87 | async def index1(request): 88 | return response.text("Hello World from {}.".format(request.app.name)) 89 | @dispatcher.parent_app.middleware("response") 90 | async def mw(request, resp): 91 | return response.text("Hello from response middleware.") 92 | @child_sanic_app.route("/test", methods=['GET', 'OPTIONS']) 93 | async def index2(request): 94 | return response.text("Hello World from {}.".format(request.app.name)) 95 | dispatcher.register_sanic_application(child_sanic_app, '/sanicchild', apply_middleware=True) 96 | tester = dispatcher.parent_app.test_client 97 | request, resp = tester.get("/sanicchild/test", gather_request=True) 98 | assert resp.status == 200 99 | assert "response middleware" in resp.text 100 | 101 | def test_child_with_child_mw(dispatcher): 102 | child_sanic_app = Sanic("child3") 103 | @dispatcher.parent_app.route("/test", methods=['GET', 'OPTIONS']) 104 | async def index1(request): 105 | return response.text("Hello World from {}.".format(request.app.name)) 106 | @child_sanic_app.middleware("response") 107 | async def mw(request, resp): 108 | return response.text("Hello from child response middleware.") 109 | @child_sanic_app.route("/test", methods=['GET', 'OPTIONS']) 110 | async def index2(request): 111 | return response.text("Hello World from {}.".format(request.app.name)) 112 | dispatcher.register_sanic_application(child_sanic_app, '/sanicchild', apply_middleware=True) 113 | tester = dispatcher.parent_app.test_client 114 | request, resp = tester.get("/sanicchild/test", gather_request=True) 115 | assert resp.status == 200 116 | assert "child response middleware" in resp.text 117 | 118 | def test_flask_child(dispatcher): 119 | from flask import Flask 120 | child_flask_app = Flask("child4") 121 | @dispatcher.parent_app.route("/test", methods=['GET', 'OPTIONS']) 122 | async def index1(request): 123 | return response.text("Hello World from {}.".format(request.app.name)) 124 | @child_flask_app.route("/test", methods=['GET', 'OPTIONS']) 125 | def index2(): 126 | return "Hello World from flask!." 127 | dispatcher.register_wsgi_application(child_flask_app, '/flaskchild', apply_middleware=True) 128 | tester = dispatcher.parent_app.test_client 129 | request, resp = tester.get("/flaskchild/test", gather_request=True) 130 | assert resp.status == 200 131 | assert "flask!" in resp.text 132 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35-sanic0083, py36-sanic1812, py{37,38,39}-sanic{1912,2012,2103} 3 | skip_missing_interpreters=true 4 | 5 | [testenv] 6 | usedevelop = True 7 | deps = 8 | sanic0083: sanic==0.8.3 9 | sanic1812: sanic==18.12.0 10 | sanic1912: sanic==19.12.5 11 | sanic2012: sanic==20.12.2 12 | sanic2103: sanic==21.03.2 13 | sanic2103: sanic-testing 14 | coverage==5.3 15 | pytest==5.2.1 16 | pytest-cov 17 | pytest-asyncio 18 | sanic{1812,1912,2012}: pytest-sanic 19 | sanic{0083,1812}: aiohttp<3.6 20 | pytest-benchmark 21 | chardet>=3,<4 22 | beautifulsoup4 23 | sanic{1812}: websockets<7.0,>=6.0 24 | sanic{1912,2012}: websockets>=7.0,<9.0 25 | sanic{2103}: websockets>=8.1,<9.0 26 | py{35,36}: uvloop<0.15 27 | flask==1.0.0 28 | commands = 29 | pytest {posargs:test --cov sanic_dispatcher} 30 | - coverage combine --append 31 | coverage report -m 32 | coverage html -i 33 | --------------------------------------------------------------------------------