Skip to content

Commit

Permalink
Add X-Original-URI support
Browse files Browse the repository at this point in the history
  • Loading branch information
Julien Sagnard committed Jan 8, 2019
1 parent a0ac0fd commit 9433d75
Show file tree
Hide file tree
Showing 12 changed files with 219 additions and 26 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ htmlcov/
*.swp
.tox/
.idea/
venv
.vscode
src
20 changes: 18 additions & 2 deletions connexion/apis/abstract.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import abc
import copy
import logging
import pathlib
import sys
Expand Down Expand Up @@ -79,7 +80,9 @@ def __init__(self, specification, base_path=None, arguments=None,
logger.debug('Options Loaded',
extra={'swagger_ui': self.options.openapi_console_ui_available,
'swagger_path': self.options.openapi_console_ui_from_dir,
'swagger_url': self.options.openapi_console_ui_path})
'swagger_url': self.options.openapi_console_ui_path,
'proxy_uri_prefix_header': self.options.proxy_uri_prefix_header
})

self._set_base_path(base_path)

Expand Down Expand Up @@ -113,7 +116,7 @@ def __init__(self, specification, base_path=None, arguments=None,
self.specification.security_definitions
)

def _set_base_path(self, base_path=None):
def _set_base_path(self, base_path):
if base_path is not None:
# update spec to include user-provided base_path
self.specification.base_path = base_path
Expand All @@ -134,6 +137,19 @@ def add_swagger_ui(self):
Adds swagger ui to {base_path}/ui/
"""

def _get_specs_behind_proxy(self, prefix_uri):
"""
Update OpenAPI base path using specified original_uri. (from header set by the proxy).
:param prefix_uri: uri to use to prefix base_path
:return: Updated raw specifications
"""

if not prefix_uri:
return self.specification.raw
specs = copy.copy(self.specification.raw)
self.specification.set_base_path(specs, prefix_uri + self.base_path)
return specs

@abc.abstractmethod
def add_auth_on_not_found(self, security, security_definitions):
"""
Expand Down
6 changes: 4 additions & 2 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ def add_openapi_json(self):

@asyncio.coroutine
def _get_openapi_json(self, req):
specs = self._get_specs_behind_proxy(req.headers.get(self.options.proxy_uri_prefix_header))
return web.Response(
status=200,
content_type='application/json',
body=self.jsonifier.dumps(self.specification.raw)
body=self.jsonifier.dumps(specs)
)

def add_swagger_ui(self):
Expand Down Expand Up @@ -114,7 +115,8 @@ def add_swagger_ui(self):
@aiohttp_jinja2.template('index.j2')
@asyncio.coroutine
def _get_swagger_ui_home(self, req):
return {'openapi_spec_url': (self.base_path +
base_path = req.headers.get(self.options.proxy_uri_prefix_header, '') + self.base_path
return {'openapi_spec_url': (base_path +
self.options.openapi_spec_path)}

def add_auth_on_not_found(self, security, security_definitions):
Expand Down
10 changes: 8 additions & 2 deletions connexion/apis/flask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ def add_openapi_json(self):
endpoint_name = "{name}_openapi_json".format(name=self.blueprint.name)
self.blueprint.add_url_rule(self.options.openapi_spec_path,
endpoint_name,
lambda: flask.jsonify(self.specification.raw))
lambda: flask.jsonify(
self._get_specs_behind_proxy(
flask.request.headers.get(self.options.proxy_uri_prefix_header)
)
)
)

def add_swagger_ui(self):
"""
Expand Down Expand Up @@ -278,9 +283,10 @@ def console_ui_home(self):
:return:
"""
base_path = flask.request.headers.get(self.options.proxy_uri_prefix_header, '') + self.base_path
return flask.render_template(
'index.j2',
openapi_spec_url=(self.base_path + self.options.openapi_spec_path)
openapi_spec_url=(base_path + self.options.openapi_spec_path)
)

def console_ui_static_files(self, filename):
Expand Down
10 changes: 10 additions & 0 deletions connexion/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ def uri_parser_class(self):
"""
return self._options.get('uri_parser_class', None)

@property
def proxy_uri_prefix_header(self):
# type: () -> str
"""
The header to use to dynamically prefix specification base_path.
This is header need to be set by reverse proxy when there is a rewrite rule.
Default: ''
"""
return self._options.get('proxy_uri_prefix_header', '')


def filter_values(dictionary):
# type: (dict) -> dict
Expand Down
42 changes: 27 additions & 15 deletions connexion/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ def version(self):
def security(self):
return self._spec.get('security')

@property
def base_path(self):
return self._get_base_path()

@base_path.setter
def base_path(self, base_path):
base_path = canonical_base_path(base_path)
self.set_base_path(self._raw_spec, base_path)
self.set_base_path(self._spec, base_path)

@abc.abstractmethod
def _get_base_path(self):
""" Return base path from specs """

@staticmethod
@abc.abstractmethod
def set_base_path(specs, base_path):
""" Set base path adapted to specification format """

def __getitem__(self, k):
return self._spec[k]

Expand Down Expand Up @@ -188,15 +207,12 @@ def response_definitions(self):
def security_definitions(self):
return self._spec.get('securityDefinitions', {})

@property
def base_path(self):
def _get_base_path(self):
return canonical_base_path(self._spec.get('basePath', ''))

@base_path.setter
def base_path(self, base_path):
base_path = canonical_base_path(base_path)
self._raw_spec['basePath'] = base_path
self._spec['basePath'] = base_path
@staticmethod
def set_base_path(specs, base_path):
specs['basePath'] = base_path

@classmethod
def _validate_spec(cls, spec):
Expand Down Expand Up @@ -231,8 +247,7 @@ def _validate_spec(cls, spec):
except OpenAPIValidationError as e:
raise InvalidSpecification.create_from(e)

@property
def base_path(self):
def _get_base_path(self):
servers = self._spec.get('servers', [])
try:
# assume we're the first server in list
Expand All @@ -247,9 +262,6 @@ def base_path(self):
base_path = ''
return canonical_base_path(base_path)

@base_path.setter
def base_path(self, base_path):
base_path = canonical_base_path(base_path)
user_servers = [{'url': base_path}]
self._raw_spec['servers'] = user_servers
self._spec['servers'] = user_servers
@staticmethod
def set_base_path(specs, base_path):
specs['servers'] = [{'url': base_path}]
39 changes: 39 additions & 0 deletions examples/openapi3/external_reverse_proxy/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
=====================
External Reverse Proxy Example
=====================

This example demonstrates how to run a connexion application behind an external reverse proxy with rewrite rule.

Setup NGINX reverse proxy:

.. code-block:: NGINX
server {
listen 9000;
listen [::]:9000;
server_name example.com;
location /reverse_proxied/ {
# Define the location of the proxy server to send the request to
proxy_pass http://localhost:8080/;
# Add prefix header
proxy_set_header X-Forwarded-Prefix $scheme://$host:$server_port/reverse_proxied;
}
}
And start application:

.. code-block:: bash
$ sudo pip3 install --upgrade connexion[swagger-ui] # install Connexion from PyPI
$ ./app.py
Swagger UI can be accessed with direct connexion:

http://localhost:8080/v1/ui

And through NGINX proxy with rewrite rule:

http://localhost:9000/reverse_proxied/v1/ui
14 changes: 14 additions & 0 deletions examples/openapi3/external_reverse_proxy/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env python3

import connexion


def hello():
return "hello"


if __name__ == '__main__':
app = connexion.FlaskApp(__name__, options={'proxy_uri_prefix_header': 'X-Forwarded-Prefix'})
app.add_api('openapi/openapi.yaml')
app.run(port=8080)

18 changes: 18 additions & 0 deletions examples/openapi3/external_reverse_proxy/openapi/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
openapi: 3.0.0
info:
title: Path-Altering Reverse Proxy Example
version: '1.0'
servers:
- url: http://localhost:8080/v1
paths:
/hello:
get:
summary: say hi
operationId: app.hello
responses:
'200':
description: hello
content:
text/plain:
schema:
type: string
58 changes: 54 additions & 4 deletions tests/aiohttp/test_aiohttp_simple_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,6 @@ def test_swagger_ui(aiohttp_api_spec_dir, aiohttp_client):
assert swagger_ui.status == 200
assert b'url = "/v1.0/swagger.json"' in (yield from swagger_ui.read())

swagger_ui = yield from app_client.get('/v1.0/ui/')
assert swagger_ui.status == 200
assert b'url = "/v1.0/swagger.json"' in (yield from swagger_ui.read())


@asyncio.coroutine
def test_swagger_ui_index(aiohttp_api_spec_dir, aiohttp_client):
Expand Down Expand Up @@ -144,6 +140,60 @@ def test_no_swagger_ui(aiohttp_api_spec_dir, aiohttp_client):
assert swagger_ui2.status == 404


@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-Original-URI header. """
app = AioHttpApp(__name__, port=5001,
specification_dir=simple_api_spec_dir,
options={'proxy_uri_prefix_header': 'X-Forwarded-Prefix'},
debug=True)
api = app.add_api('swagger.yaml')

app_client = yield from aiohttp_client(app.app)
headers = {'X-Forwarded-Prefix': '/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-Original-URI header. """
app = AioHttpApp(__name__, port=5001,
specification_dir=simple_api_spec_dir,
options={'proxy_uri_prefix_header': 'My-URI-Header'},
debug=True)
api = app.add_api('openapi.yaml')

app_client = yield from aiohttp_client(app.app)
headers = {'My-URI-Header': '/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"
assert api.specification.raw.get('servers', [{}])[0].get('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 basePath should have been updated"


@asyncio.coroutine
def test_middlewares(aiohttp_api_spec_dir, aiohttp_client):
@asyncio.coroutine
Expand Down
24 changes: 23 additions & 1 deletion tests/api/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ def test_app(simple_app):
assert greeting_response['greeting'] == 'Hello jsantos'


def test_openapi_json_behind_proxy(simple_app):
""" Verify the swagger.json file is returned with base_path updated according to X-Original-URI header. """
app_client = simple_app.app.test_client()
headers = {'X-Forwarded-Prefix': '/behind/proxy'}

swagger_ui = app_client.get('/v1.0/ui/', headers=headers)
assert swagger_ui.status_code == 200

swagger_json = app_client.get('/v1.0/' + simple_app._spec_file.replace('.yaml', '.json'), headers=headers)
assert swagger_json.status_code == 200
assert swagger_json.headers.get('Content-Type') == 'application/json'
json_ = json.loads(swagger_json.data.decode('utf-8'))

if simple_app._spec_file == 'openapi.yaml':
assert b'url: "/behind/proxy/v1.0/openapi.json"' in swagger_ui.data
assert json_.get('servers', [{}])[0].get('url') == '/behind/proxy/v1.0', "basePath should contains original URI"
else:
assert b'url = "/behind/proxy/v1.0/swagger.json"' in swagger_ui.data
assert json_.get('basePath') == '/behind/proxy/v1.0', "basePath should contains original URI"


def test_produce_decorator(simple_app):
app_client = simple_app.app.test_client()

Expand Down Expand Up @@ -264,14 +285,15 @@ def test_get_unicode_response(simple_app):
app_client = simple_app.app.test_client()
resp = app_client.get('/v1.0/get_unicode_response')
actualJson = {u'currency': u'\xa3', u'key': u'leena'}
assert json.loads(resp.data.decode('utf-8','replace')) == actualJson
assert json.loads(resp.data.decode('utf-8', 'replace')) == actualJson


def test_get_enum_response(simple_app):
app_client = simple_app.app.test_client()
resp = app_client.get('/v1.0/get_enum_response')
assert resp.status_code == 200


def test_get_httpstatus_response(simple_app):
app_client = simple_app.app.test_client()
resp = app_client.get('/v1.0/get_httpstatus_response')
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def build_app_from_fixture(api_spec_folder, spec_file='openapi.yaml', **kwargs):
cnx_app = App(__name__,
port=5001,
specification_dir=FIXTURES_FOLDER / api_spec_folder,
options={'proxy_uri_prefix_header': 'X-Forwarded-Prefix'},
debug=debug)

cnx_app.add_api(spec_file, **kwargs)
Expand Down

0 comments on commit 9433d75

Please sign in to comment.