Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add empty connexion middleware #1502

Merged
merged 4 commits into from
Mar 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions connexion/apps/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def __init__(self, import_name, api_cls, port=None, specification_dir='',
self.server = server
self.server_args = dict() if server_args is None else server_args
self.app = self.create_app()
self._apply_middleware()

# we get our application root path to avoid duplicating logic
self.root_path = self.get_root_path()
Expand All @@ -78,6 +79,12 @@ def create_app(self):
Creates the user framework application
"""

@abc.abstractmethod
def _apply_middleware(self):
"""
Apply middleware to application
"""

@abc.abstractmethod
def get_root_path(self):
"""
Expand Down Expand Up @@ -243,12 +250,3 @@ def run(self, port=None, server=None, debug=None, host=None, **options): # prag
:type debug: bool
:param options: options to be forwarded to the underlying server
"""

def __call__(self, environ, start_response): # pragma: no cover
"""
Makes the class callable to be WSGI-compliant. As Flask is used to handle requests,
this is a passthrough-call to the Flask callable class.
This is an abstraction to avoid directly referencing the app attribute from outside the
class and protect it from unwanted modification.
"""
return self.app(environ, start_response)
20 changes: 19 additions & 1 deletion connexion/apps/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from decimal import Decimal
from types import FunctionType # NOQA

import a2wsgi
import flask
import werkzeug.exceptions
from flask import json, signals

from ..apis.flask_api import FlaskApi
from ..exceptions import ProblemException
from ..middleware import ConnexionMiddleware
from ..problem import problem
from .abstract import AbstractApp

Expand All @@ -28,8 +30,10 @@ def __init__(self, import_name, server='flask', extra_files=None, **kwargs):

See :class:`~connexion.AbstractApp` for additional parameters.
"""
super().__init__(import_name, FlaskApi, server=server, **kwargs)
self.extra_files = extra_files or []
self.middleware = None

super().__init__(import_name, FlaskApi, server=server, **kwargs)

def create_app(self):
app = flask.Flask(self.import_name, **self.server_args)
Expand All @@ -38,6 +42,14 @@ def create_app(self):
app.url_map.converters['int'] = IntegerConverter
return app

def _apply_middleware(self):
middlewares = [*ConnexionMiddleware.default_middlewares,
a2wsgi.WSGIMiddleware]
self.middleware = ConnexionMiddleware(self.app.wsgi_app, middlewares=middlewares)

# Wrap with ASGI to WSGI middleware for usage with development server and test client
self.app.wsgi_app = a2wsgi.ASGIMiddleware(self.middleware)

def get_root_path(self):
return pathlib.Path(self.app.root_path)

Expand Down Expand Up @@ -147,6 +159,12 @@ def run(self,
else:
raise Exception(f'Server {self.server} not recognized')

def __call__(self, scope, receive, send): # pragma: no cover
"""
ASGI interface. Calls the middleware wrapped around the wsgi app.
"""
return self.middleware(scope, receive, send)


class FlaskJSONEncoder(json.JSONEncoder):
def default(self, o):
Expand Down
1 change: 1 addition & 0 deletions connexion/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .main import ConnexionMiddleware # NOQA
61 changes: 61 additions & 0 deletions connexion/middleware/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import pathlib
import typing as t

from starlette.types import ASGIApp, Receive, Scope, Send


class ConnexionMiddleware:

default_middlewares = [
]

def __init__(
self,
app: ASGIApp,
middlewares: t.Optional[t.List[t.Type[ASGIApp]]] = None
):
"""High level Connexion middleware that manages a list o middlewares wrapped around an
RobbeSneyders marked this conversation as resolved.
Show resolved Hide resolved
RobbeSneyders marked this conversation as resolved.
Show resolved Hide resolved
application.

:param app: App to wrap middleware around.
:param middlewares: List of middlewares to wrap around app. The list should be ordered
from outer to inner middleware.
"""
if middlewares is None:
middlewares = self.default_middlewares
self.app, self.apps = self._apply_middlewares(app, middlewares)

self._routing_middleware = None

@staticmethod
def _apply_middlewares(app: ASGIApp, middlewares: t.List[t.Type[ASGIApp]]) \
-> t.Tuple[ASGIApp, t.Iterable[ASGIApp]]:
"""Apply all middlewares to the provided app.

:param app: App to wrap in middlewares.
:param middlewares: List of middlewares to wrap around app. The list should be ordered
from outer to inner middleware.

:return: App with all middlewares applied.
"""
apps = []
for middleware in reversed(middlewares):
app = middleware(app)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we add configuration to the different middlewares?
Typically, middleware can have additional keyword arguments, but it's not clear to me how we can do this here? (Unless with something like functools.partial)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, currently functools.partial is probably the best way. Open to better options, but it might be hard without tying the implementation to specific middleware types.

I don't think we need to solve this now though. Once we get the internals working, we can still spend some time on the API we want to offer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, just wanted to raise it to be aware of if there was a straightforward solution for it.

apps.append(app)
return app, reversed(apps)

def add_api(
self,
specification: t.Union[pathlib.Path, str, dict],
base_path: t.Optional[str] = None,
arguments: t.Optional[dict] = None,
) -> None:
"""Add an API to the underlying routing middleware based on a OpenAPI spec.

:param specification: OpenAPI spec as dict or path to file.
:param base_path: Base path where to add this API.
:param arguments: Jinja arguments to replace in the spec.
"""

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
9 changes: 5 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,19 @@ def read_version(package):
'clickclick>=1.2,<21',
'jsonschema>=2.5.1,<5',
'PyYAML>=5.1,<7',
'requests>=2.19.1,<3',
'requests>=2.27,<3',
'inflection>=0.3.1,<0.6',
'werkzeug>=1.0,<3',
'werkzeug>=2,<3',
'importlib-metadata>=1 ; python_version<"3.8"',
'packaging>=20',
'starlette>=0.15,<1',
]

swagger_ui_require = 'swagger-ui-bundle>=0.0.2,<0.1'

flask_require = [
'flask>=1.0.4,<3',
'itsdangerous>=0.24',
'flask>=2,<3',
'a2wsgi>=1.1,<2',
]

tests_require = [
Expand Down
8 changes: 4 additions & 4 deletions tests/api/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,23 @@ def test_errors(problem_app):

get_problem = app_client.get('/v1.0/problem') # type: flask.Response
assert get_problem.content_type == 'application/problem+json'
assert get_problem.status_code == 418
assert get_problem.status_code == 402
assert get_problem.headers['x-Test-Header'] == 'In Test'
error_problem = json.loads(get_problem.data.decode('utf-8', 'replace'))
assert error_problem['type'] == 'http://www.example.com/error'
assert error_problem['title'] == 'Some Error'
assert error_problem['detail'] == 'Something went wrong somewhere'
assert error_problem['status'] == 418
assert error_problem['status'] == 402
assert error_problem['instance'] == 'instance1'

get_problem2 = app_client.get('/v1.0/other_problem') # type: flask.Response
assert get_problem2.content_type == 'application/problem+json'
assert get_problem2.status_code == 418
assert get_problem2.status_code == 402
error_problem2 = json.loads(get_problem2.data.decode('utf-8', 'replace'))
assert error_problem2['type'] == 'about:blank'
assert error_problem2['title'] == 'Some Error'
assert error_problem2['detail'] == 'Something went wrong somewhere'
assert error_problem2['status'] == 418
assert error_problem2['status'] == 402
assert error_problem2['instance'] == 'instance1'

problematic_json = app_client.get(
Expand Down
6 changes: 4 additions & 2 deletions tests/api/test_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ def test_headers_jsonifier(simple_app):

response = app_client.post('/v1.0/goodday/dan', data={}) # type: flask.Response
assert response.status_code == 201
assert response.headers["Location"] == "http://localhost/my/uri"
# Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352)
assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"]


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

response = app_client.post('/v1.0/goodevening/dan', data={}) # type: flask.Response
assert response.status_code == 201
assert response.headers["Location"] == "http://localhost/my/uri"
# Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352)
assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"]


def test_header_not_returned(simple_openapi_app):
Expand Down
33 changes: 33 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
from connexion import App
from connexion.security import FlaskSecurityHandlerFactory
from werkzeug.test import Client, EnvironBuilder

logging.basicConfig(level=logging.DEBUG)

Expand All @@ -31,6 +32,38 @@ def json(self):
return json.loads(self.text)


def fixed_get_environ():
"""See https://github.com/pallets/werkzeug/issues/2347"""

original_get_environ = EnvironBuilder.get_environ

def f(self):
result = original_get_environ(self)
result.pop("HTTP_CONTENT_TYPE", None)
result.pop("HTTP_CONTENT_LENGTH", None)
return result

return f


EnvironBuilder.get_environ = fixed_get_environ()


def buffered_open():
"""For use with ASGI middleware"""

original_open = Client.open

def f(*args, **kwargs):
kwargs["buffered"] = True
return original_open(*args, **kwargs)

return f


Client.open = buffered_open()


# Helper fixtures functions
# =========================

Expand Down
4 changes: 2 additions & 2 deletions tests/fakeapi/hello/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,15 @@ def with_problem():
raise ProblemException(type='http://www.example.com/error',
title='Some Error',
detail='Something went wrong somewhere',
status=418,
status=402,
instance='instance1',
headers={'x-Test-Header': 'In Test'})


def with_problem_txt():
raise ProblemException(title='Some Error',
detail='Something went wrong somewhere',
status=418,
status=402,
instance='instance1')


Expand Down