Skip to content

Commit

Permalink
Merge pull request #823 from dtkav/dynamic_ui_path
Browse files Browse the repository at this point in the history
Serve correct openapi spec basepath when path is altered by reverse-proxy
  • Loading branch information
hjacobs authored Dec 17, 2019
2 parents f6e8b8c + e859609 commit c08111b
Show file tree
Hide file tree
Showing 14 changed files with 621 additions and 27 deletions.
34 changes: 28 additions & 6 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,26 @@ def _set_base_path(self, base_path):
def normalize_string(string):
return re.sub(r'[^a-zA-Z0-9]', '_', string.strip('/'))

def _base_path_for_prefix(self, request):
"""
returns a modified basePath which includes the incoming request's
path prefix.
"""
base_path = self.base_path
if not request.path.startswith(self.base_path):
prefix = request.path.split(self.base_path)[0]
base_path = prefix + base_path
return base_path

def _spec_for_prefix(self, request):
"""
returns a spec with a modified basePath / servers block
which corresponds to the incoming request path.
This is needed when behind a path-altering reverse proxy.
"""
base_path = self._base_path_for_prefix(request)
return self.specification.with_base_path(base_path).raw

def add_openapi_json(self):
"""
Adds openapi json to {base_path}/openapi.json
Expand All @@ -135,7 +155,8 @@ def add_openapi_yaml(self):
if not self.options.openapi_spec_path.endswith("json"):
return

openapi_spec_path_yaml = self.options.openapi_spec_path[:-len("json")] + "yaml"
openapi_spec_path_yaml = \
self.options.openapi_spec_path[:-len("json")] + "yaml"
logger.debug('Adding spec yaml: %s/%s', self.base_path,
openapi_spec_path_yaml)
self.subapp.router.add_route(
Expand All @@ -145,19 +166,19 @@ def add_openapi_yaml(self):
)

@asyncio.coroutine
def _get_openapi_json(self, req):
def _get_openapi_json(self, request):
return web.Response(
status=200,
content_type='application/json',
body=self.jsonifier.dumps(self.specification.raw)
body=self.jsonifier.dumps(self._spec_for_prefix(request))
)

@asyncio.coroutine
def _get_openapi_yaml(self, req):
def _get_openapi_yaml(self, request):
return web.Response(
status=200,
content_type='text/yaml',
body=yamldumper(self.specification.raw)
body=yamldumper(self._spec_for_prefix(request))
)

def add_swagger_ui(self):
Expand Down Expand Up @@ -213,8 +234,9 @@ def redirect(request):
@aiohttp_jinja2.template('index.j2')
@asyncio.coroutine
def _get_swagger_ui_home(self, req):
base_path = self._base_path_for_prefix(req)
template_variables = {
'openapi_spec_url': (self.base_path + self.options.openapi_spec_path)
'openapi_spec_url': (base_path + self.options.openapi_spec_path)
}
if self.options.openapi_console_ui_config is not None:
template_variables['configUrl'] = 'swagger-ui-config.json'
Expand Down
39 changes: 29 additions & 10 deletions connexion/apis/flask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ def add_openapi_json(self):
logger.debug('Adding spec json: %s/%s', self.base_path,
self.options.openapi_spec_path)
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))
self._handlers.get_json_spec)

def add_openapi_yaml(self):
"""
Expand All @@ -46,18 +47,15 @@ def add_openapi_yaml(self):
if not self.options.openapi_spec_path.endswith("json"):
return

openapi_spec_path_yaml = self.options.openapi_spec_path[:-len("json")] + "yaml"
openapi_spec_path_yaml = \
self.options.openapi_spec_path[:-len("json")] + "yaml"
logger.debug('Adding spec yaml: %s/%s', self.base_path,
openapi_spec_path_yaml)
endpoint_name = "{name}_openapi_yaml".format(name=self.blueprint.name)
self.blueprint.add_url_rule(
openapi_spec_path_yaml,
endpoint_name,
lambda: FlaskApi._build_response(
status_code=200,
mimetype="text/yaml",
data=yamldumper(self.specification.raw)
)
self._handlers.get_yaml_spec
)

def add_swagger_ui(self):
Expand Down Expand Up @@ -119,7 +117,7 @@ def _add_operation_internal(self, method, path, operation):
def _handlers(self):
# type: () -> InternalHandlers
if not hasattr(self, '_internal_handlers'):
self._internal_handlers = InternalHandlers(self.base_path, self.options)
self._internal_handlers = InternalHandlers(self.base_path, self.options, self.specification)
return self._internal_handlers

@classmethod
Expand Down Expand Up @@ -262,18 +260,25 @@ class InternalHandlers(object):
Flask handlers for internally registered endpoints.
"""

def __init__(self, base_path, options):
def __init__(self, base_path, options, specification):
self.base_path = base_path
self.options = options
self.specification = specification

def console_ui_home(self):
"""
Home page of the OpenAPI Console UI.
:return:
"""
openapi_json_route_name = "{blueprint}.{prefix}_openapi_json"
escaped = flask_utils.flaskify_endpoint(self.base_path)
openapi_json_route_name = openapi_json_route_name.format(
blueprint=escaped,
prefix=escaped
)
template_variables = {
'openapi_spec_url': (self.base_path + self.options.openapi_spec_path)
'openapi_spec_url': flask.url_for(openapi_json_route_name)
}
if self.options.openapi_console_ui_config is not None:
template_variables['configUrl'] = 'swagger-ui-config.json'
Expand All @@ -289,3 +294,17 @@ def console_ui_static_files(self, filename):
# convert PosixPath to str
static_dir = str(self.options.openapi_console_ui_from_dir)
return flask.send_from_directory(static_dir, filename)

def get_json_spec(self):
return flask.jsonify(self._spec_for_prefix())

def get_yaml_spec(self):
return yamldumper(self._spec_for_prefix()), 200, {"Content-Type": "text/yaml"}

def _spec_for_prefix(self):
"""
Modify base_path in the spec based on incoming url
This fixes problems with reverse proxies changing the path.
"""
base_path = flask.url_for(flask.request.endpoint).rsplit("/", 1)[0]
return self.specification.with_base_path(base_path).raw
8 changes: 8 additions & 0 deletions connexion/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,20 @@ def enforce_string_keys(obj):
return Swagger2Specification(spec)
return OpenAPISpecification(spec)

def clone(self):
return type(self)(copy.deepcopy(self._raw_spec))

@classmethod
def load(cls, spec, arguments=None):
if not isinstance(spec, dict):
return cls.from_file(spec, arguments=arguments)
return cls.from_dict(spec)

def with_base_path(self, base_path):
new_spec = self.clone()
new_spec.base_path = base_path
return new_spec


class Swagger2Specification(Specification):
yaml_name = 'swagger.yaml'
Expand Down
58 changes: 58 additions & 0 deletions examples/openapi3/reverseproxy/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
=====================
Reverse Proxy Example
=====================

This example demonstrates how to run a connexion application behind a path-altering reverse proxy.

You can either set the path in your app, or set the ``X-Forwarded-Path`` header.

Running:

.. code-block:: bash
$ sudo pip3 install --upgrade connexion[swagger-ui] # install Connexion from PyPI
$ ./app.py
Now open your browser and go to http://localhost:8080/reverse_proxied/ui/ to see the Swagger UI.


You can also use the ``X-Forwarded-Path`` header to modify the reverse proxy path.
For example:

.. code-block:: bash
curl -H "X-Forwarded-Path: /banana/" http://localhost:8080/openapi.json
{
"servers" : [
{
"url" : "banana/"
}
],
"paths" : {
"/hello" : {
"get" : {
"responses" : {
"200" : {
"description" : "hello",
"content" : {
"text/plain" : {
"schema" : {
"type" : "string"
}
}
}
}
},
"operationId" : "app.hello",
"summary" : "say hi"
}
}
},
"openapi" : "3.0.0",
"info" : {
"version" : "1.0",
"title" : "Path-Altering Reverse Proxy Example"
}
}
79 changes: 79 additions & 0 deletions examples/openapi3/reverseproxy/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
'''
example of connexion running behind a path-altering reverse-proxy
NOTE this demo is not secure by default!!
You'll want to make sure these headers are coming from your proxy, and not
directly from users on the web!
'''
import logging

import connexion


# adapted from http://flask.pocoo.org/snippets/35/
class ReverseProxied(object):
'''Wrap the application in this middleware and configure the
reverse proxy to add these headers, to let you quietly bind
this to a URL other than / and to an HTTP scheme that is
different than what is used locally.
In nginx:
location /proxied {
proxy_pass http://192.168.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Forwarded-Path /proxied;
}
:param app: the WSGI application
:param script_name: override the default script name (path)
:param scheme: override the default scheme
:param server: override the default server
'''

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):
logging.warning(
"this demo is not secure by default!! "
"You'll want to make sure these headers are coming from your proxy, "
"and not directly from users on the web!"
)
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)


def hello():
return "hello"


if __name__ == '__main__':
app = connexion.FlaskApp(__name__)
app.add_api('openapi.yaml')
flask_app = app.app
proxied = ReverseProxied(
flask_app.wsgi_app,
script_name='/reverse_proxied/'
)
flask_app.wsgi_app = proxied
app.run(port=8080)
16 changes: 16 additions & 0 deletions examples/openapi3/reverseproxy/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
openapi: 3.0.0
info:
title: Path-Altering Reverse Proxy Example
version: '1.0'
paths:
/hello:
get:
summary: say hi
operationId: app.hello
responses:
'200':
description: hello
content:
text/plain:
schema:
type: string
58 changes: 58 additions & 0 deletions examples/openapi3/reverseproxy_aiohttp/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
=====================
Reverse Proxy Example
=====================

This example demonstrates how to run a connexion application behind a path-altering reverse proxy.

You can either set the path in your app, or set the ``X-Forwarded-Path`` header.

Running:

.. code-block:: bash
$ sudo pip3 install --upgrade connexion[swagger-ui] aiohttp-remotes
$ ./app.py
Now open your browser and go to http://localhost:8080/reverse_proxied/ui/ to see the Swagger UI.


You can also use the ``X-Forwarded-Path`` header to modify the reverse proxy path.
For example:

.. code-block:: bash
curl -H "X-Forwarded-Path: /banana/" http://localhost:8080/openapi.json
{
"servers" : [
{
"url" : "banana"
}
],
"paths" : {
"/hello" : {
"get" : {
"responses" : {
"200" : {
"description" : "hello",
"content" : {
"text/plain" : {
"schema" : {
"type" : "string"
}
}
}
}
},
"operationId" : "app.hello",
"summary" : "say hi"
}
}
},
"openapi" : "3.0.0",
"info" : {
"version" : "1.0",
"title" : "Path-Altering Reverse Proxy Example"
}
}
Loading

0 comments on commit c08111b

Please sign in to comment.