diff --git a/connexion/__init__.py b/connexion/__init__.py index 7b3d7d8..902e0be 100755 --- a/connexion/__init__.py +++ b/connexion/__init__.py @@ -37,7 +37,7 @@ except ImportError: # pragma: no cover App = FlaskApp Api = FlaskApi -if sys.version_info[0] >= 3: # pragma: no cover +if sys.version_info >= (3, 5, 3): # pragma: no cover try: from .apis.aiohttp_api import AioHttpApi from .apps.aiohttp_app import AioHttpApp diff --git a/examples/openapi3/reverseproxy/app.py b/examples/openapi3/reverseproxy/app.py index 80fefbd..7a1652c 100755 --- a/examples/openapi3/reverseproxy/app.py +++ b/examples/openapi3/reverseproxy/app.py @@ -10,6 +10,7 @@ directly from users on the web! import connexion + # adapted from http://flask.pocoo.org/snippets/35/ class ReverseProxied(object): '''Wrap the application in this middleware and configure the diff --git a/examples/openapi3/reverseproxy_aiohttp/app.py b/examples/openapi3/reverseproxy_aiohttp/app.py index d96c933..df6654c 100755 --- a/examples/openapi3/reverseproxy_aiohttp/app.py +++ b/examples/openapi3/reverseproxy_aiohttp/app.py @@ -4,11 +4,13 @@ example of aiohttp connexion running behind a path-altering reverse-proxy ''' import json + import connexion -from yarl import URL + from aiohttp import web -from aiohttp_remotes.x_forwarded import XForwardedBase from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders +from aiohttp_remotes.x_forwarded import XForwardedBase +from yarl import URL X_FORWARDED_PATH = "X-Forwarded-Path" diff --git a/setup.py b/setup.py index 3d7907a..2de1a6a 100755 --- a/setup.py +++ b/setup.py @@ -47,9 +47,10 @@ tests_require = [ swagger_ui_require ] -if sys.version_info[0] >= 3: +if sys.version_info >= (3, 5, 3): tests_require.extend(aiohttp_require) tests_require.append('pytest-aiohttp') + tests_require.append('aiohttp-remotes') class PyTest(TestCommand): @@ -61,7 +62,7 @@ class PyTest(TestCommand): self.cov = None self.pytest_args = ['--cov', 'connexion', '--cov-report', 'term-missing', '-v'] - if sys.version_info[0] < 3: + if sys.version_info < (3, 5, 3): self.pytest_args.append('--cov-config=py2-coveragerc') self.pytest_args.append('--ignore=tests/aiohttp') else: diff --git a/tests/aiohttp/test_aiohttp_reverse_proxy.py b/tests/aiohttp/test_aiohttp_reverse_proxy.py new file mode 100644 index 0000000..aae96d6 --- /dev/null +++ b/tests/aiohttp/test_aiohttp_reverse_proxy.py @@ -0,0 +1,132 @@ +import asyncio + +from aiohttp import web +from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders +from aiohttp_remotes.x_forwarded import XForwardedBase +from connexion import AioHttpApp +from yarl import URL + +X_FORWARDED_PATH = "X-Forwarded-Path" + + +class XPathForwarded(XForwardedBase): + + def __init__(self, num=1): + self._num = num + + def get_forwarded_path(self, headers): + forwarded_host = headers.getall(X_FORWARDED_PATH, []) + if len(forwarded_host) > 1: + raise TooManyHeaders(X_FORWARDED_PATH) + return forwarded_host[0] if forwarded_host else None + + @web.middleware + async def middleware(self, request, handler): + try: + overrides = {} + headers = request.headers + + forwarded_for = self.get_forwarded_for(headers) + if forwarded_for: + overrides['remote'] = str(forwarded_for[-self._num]) + + proto = self.get_forwarded_proto(headers) + if proto: + overrides['scheme'] = proto[-self._num] + + host = self.get_forwarded_host(headers) + if host is not None: + overrides['host'] = host + + prefix = self.get_forwarded_path(headers) + if prefix is not None: + prefix = '/' + prefix.strip('/') + '/' + request_path = URL(request.path.lstrip('/')) + overrides['rel_url'] = URL(prefix).join(request_path) + + request = request.clone(**overrides) + + return await handler(request) + except RemoteError as exc: + exc.log(request) + await self.raise_error(request) + + + @asyncio.coroutine + def test_swagger_json_behind_proxy(simple_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is returned with base_path updated + according to X-Forwarded-Path header. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=simple_api_spec_dir, + debug=True) + api = app.add_api('swagger.yaml') + + aio = app.app + reverse_proxied = XPathForwarded() + aio.middlewares.append(reverse_proxied.middleware) + + app_client = yield from aiohttp_client(app.app) + headers = {'X-Forwarded-Path': '/behind/proxy'} + + swagger_ui = yield from app_client.get('/v1.0/ui/', headers=headers) + assert swagger_ui.status == 200 + assert b'url = "/behind/proxy/v1.0/swagger.json"' in ( + yield from swagger_ui.read() + ) + + swagger_json = yield from app_client.get('/v1.0/swagger.json', + headers=headers) + assert swagger_json.status == 200 + assert swagger_json.headers.get('Content-Type') == 'application/json' + json_ = yield from swagger_json.json() + + assert api.specification.raw['basePath'] == '/v1.0', \ + "Original specifications should not have been changed" + + assert json_.get('basePath') == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + + json_['basePath'] = api.specification.raw['basePath'] + assert api.specification.raw == json_, \ + "Only basePath should have been updated" + + + @asyncio.coroutine + def test_openapi_json_behind_proxy(simple_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is returned with base_path updated + according to X-Forwarded-Path header. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=simple_api_spec_dir, + debug=True) + + api = app.add_api('openapi.yaml') + + aio = app.app + reverse_proxied = XPathForwarded() + aio.middlewares.append(reverse_proxied.middleware) + + app_client = yield from aiohttp_client(app.app) + headers = {'X-Forwarded-Path': '/behind/proxy'} + + swagger_ui = yield from app_client.get('/v1.0/ui/', headers=headers) + assert swagger_ui.status == 200 + assert b'url: "/behind/proxy/v1.0/openapi.json"' in ( + yield from swagger_ui.read() + ) + + swagger_json = yield from app_client.get('/v1.0/openapi.json', + headers=headers) + assert swagger_json.status == 200 + assert swagger_json.headers.get('Content-Type') == 'application/json' + json_ = yield from swagger_json.json() + + assert json_.get('servers', [{}])[0].get('url') == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + + url = api.specification.raw.get('servers', [{}])[0].get('url') + assert url != '/behind/proxy/v1.0', \ + "Original specifications should not have been changed" + + json_['servers'] = api.specification.raw.get('servers') + assert api.specification.raw == json_, \ + "Only there servers block should have been updated" diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index e5a787a..bed0c44 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -1,6 +1,7 @@ import json from struct import unpack +import yaml from werkzeug.test import Client, EnvironBuilder from connexion.apps.flask_app import FlaskJSONEncoder @@ -43,6 +44,36 @@ def test_app(simple_app): assert greeting_response['greeting'] == 'Hello jsantos' +def test_openapi_yaml_behind_proxy(reverse_proxied_app): + """ Verify the swagger.json file is returned with base_path updated + according to X-Original-URI header. + """ + app_client = reverse_proxied_app.app.test_client() + + headers = {'X-Forwarded-Path': '/behind/proxy'} + + swagger_ui = app_client.get('/v1.0/ui/', headers=headers) + assert swagger_ui.status_code == 200 + + openapi_yaml = app_client.get( + '/v1.0/' + reverse_proxied_app._spec_file, + headers=headers + ) + assert openapi_yaml.status_code == 200 + assert openapi_yaml.headers.get('Content-Type') == 'text/yaml' + spec = yaml.load(openapi_yaml.data.decode('utf-8')) + + if reverse_proxied_app._spec_file == 'swagger.yaml': + assert b'url = "/behind/proxy/v1.0/swagger.json"' in swagger_ui.data + assert spec.get('basePath') == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + else: + assert b'url: "/behind/proxy/v1.0/openapi.json"' in swagger_ui.data + url = spec.get('servers', [{}])[0].get('url') + assert url == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + + def test_produce_decorator(simple_app): app_client = simple_app.app.test_client() diff --git a/tests/conftest.py b/tests/conftest.py index a921318..67eeb9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,6 +128,44 @@ def simple_openapi_app(request): return build_app_from_fixture('simple', request.param, validate_responses=True) +@pytest.fixture(scope="session", params=SPECS) +def reverse_proxied_app(request): + + # adapted from http://flask.pocoo.org/snippets/35/ + class ReverseProxied(object): + + def __init__(self, app, script_name=None, scheme=None, server=None): + self.app = app + self.script_name = script_name + self.scheme = scheme + self.server = server + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_FORWARDED_PATH', '') or self.script_name + if script_name: + environ['SCRIPT_NAME'] = "/" + script_name.lstrip("/") + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO_OLD'] = path_info + environ['PATH_INFO'] = path_info[len(script_name):] + scheme = environ.get('HTTP_X_SCHEME', '') or self.scheme + if scheme: + environ['wsgi.url_scheme'] = scheme + server = environ.get('HTTP_X_FORWARDED_SERVER', '') or self.server + if server: + environ['HTTP_HOST'] = server + return self.app(environ, start_response) + + app = build_app_from_fixture('simple', request.param, validate_responses=True) + flask_app = app.app + proxied = ReverseProxied( + flask_app.wsgi_app, + script_name='/reverse_proxied/' + ) + flask_app.wsgi_app = proxied + return app + + @pytest.fixture(scope="session", params=SPECS) def snake_case_app(request): return build_app_from_fixture('snake_case', request.param,