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

Added aiohttp support #530

Merged
merged 13 commits into from
Apr 9, 2018
13 changes: 13 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,17 @@ Flask with uWSGI`_ (this is common):
app = connexion.App(__name__, specification_dir='swagger/')
application = app.app # expose global WSGI application object

You can use the ``aiohttp`` framework as server backend as well:

.. code-block:: python

import connexion

app = connexion.AioHttpApp(__name__, specification_dir='swagger/')
app.run(port=8080)

Copy link
Contributor

Choose a reason for hiding this comment

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

Here we could add something like
NOTE: Also check aiohttp handler examples

**NOTE:** Also check aiohttp handler examples_.

Set up and run the installation code:

.. code-block:: bash
Expand All @@ -427,6 +438,8 @@ See the `uWSGI documentation`_ for more information.

.. _using Flask with uWSGI: http://flask.pocoo.org/docs/latest/deploying/uwsgi/
.. _uWSGI documentation: https://uwsgi-docs.readthedocs.org/
.. _examples: https://docs.aiohttp.org/en/stable/web.html#handler


Documentation
=============
Expand Down
28 changes: 21 additions & 7 deletions connexion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,39 @@
from .decorators.produces import NoContent # NOQA
from .resolver import Resolution, Resolver, RestyResolver # NOQA

try:
from .apis.flask_api import FlaskApi
from .apps.flask_app import FlaskApp
from flask import request # NOQA
except ImportError as e: # pragma: no cover
import sys
import sys


def not_installed_error(): # pragma: no cover
import six
import functools

def _required_lib(exec_info, *args, **kwargs):
six.reraise(*exec_info)

_flask_not_installed_error = functools.partial(_required_lib, sys.exc_info())
return functools.partial(_required_lib, sys.exc_info())


try:
from .apis.flask_api import FlaskApi
from .apps.flask_app import FlaskApp
from flask import request # NOQA
except ImportError: # pragma: no cover
_flask_not_installed_error = not_installed_error()
FlaskApi = _flask_not_installed_error
FlaskApp = _flask_not_installed_error

App = FlaskApp
Api = FlaskApi

if sys.version_info[0] >= 3: # pragma: no cover
Copy link
Contributor

Choose a reason for hiding this comment

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

What I meant here is that everywhere else in the code the version check looks like if sys.version_info >= (3, 4): ...
Not sure if there is any special reason to do it differently. Not critical though.

try:
from .apis.aiohttp_api import AioHttpApi
from .apps.aiohttp_app import AioHttpApp
except ImportError: # pragma: no cover
_aiohttp_not_installed_error = not_installed_error()
AioHttpApi = _aiohttp_not_installed_error
AioHttpApp = _aiohttp_not_installed_error

# This version is replaced during release process.
__version__ = '2018.0.dev1'
26 changes: 20 additions & 6 deletions connexion/apis/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..operation import Operation
from ..options import ConnexionOptions
from ..resolver import Resolver
from ..utils import Jsonifier

MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent
SWAGGER_UI_PATH = MODULE_PATH / 'vendor' / 'swagger-ui'
Expand All @@ -24,7 +25,14 @@
logger = logging.getLogger('connexion.apis.abstract')


@six.add_metaclass(abc.ABCMeta)
class AbstractAPIMeta(abc.ABCMeta):

def __init__(cls, name, bases, attrs):
abc.ABCMeta.__init__(cls, name, bases, attrs)
cls._set_jsonifier()


@six.add_metaclass(AbstractAPIMeta)
class AbstractAPI(object):
"""
Defines an abstract interface for a Swagger API
Expand Down Expand Up @@ -297,14 +305,20 @@ def get_response(self, response, mimetype=None, request=None):

@classmethod
@abc.abstractmethod
def json_loads(self, data):
def get_connexion_response(cls, response):
"""
API specific JSON loader.

:param data:
:return:
This method converts the user framework response to a ConnexionResponse.
:param response: A response to cast.
"""

def json_loads(self, data):
return self.jsonifier.loads(data)

@classmethod
def _set_jsonifier(cls):
import json
cls.jsonifier = Jsonifier(json)


def canonical_base_path(base_path):
"""
Expand Down
266 changes: 266 additions & 0 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import asyncio
import logging
import re
from urllib.parse import parse_qs

import jinja2

import aiohttp_jinja2
from aiohttp import web
from aiohttp.web_exceptions import HTTPNotFound
from connexion.apis.abstract import AbstractAPI
from connexion.exceptions import OAuthProblem
from connexion.handlers import AuthErrorHandler
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
from connexion.utils import Jsonifier, is_json_mimetype

try:
import ujson as json
from functools import partial
json.dumps = partial(json.dumps, escape_forward_slashes=True)

except ImportError: # pragma: no cover
import json

logger = logging.getLogger('connexion.apis.aiohttp_api')


@web.middleware
@asyncio.coroutine
def oauth_problem_middleware(request, handler):
try:
response = yield from handler(request)
except OAuthProblem as oauth_error:
return web.Response(
status=oauth_error.code,
body=json.dumps(oauth_error.description).encode(),
content_type='application/problem+json'
)
return response


class AioHttpApi(AbstractAPI):
def __init__(self, *args, **kwargs):
self.subapp = web.Application(
debug=kwargs.get('debug', False),
middlewares=[oauth_problem_middleware]
)
AbstractAPI.__init__(self, *args, **kwargs)

aiohttp_jinja2.setup(
self.subapp,
loader=jinja2.FileSystemLoader(
str(self.options.openapi_console_ui_from_dir)
)
)
middlewares = self.options.as_dict().get('middlewares', [])
self.subapp.middlewares.extend(middlewares)

def _set_base_path(self, base_path):
AbstractAPI._set_base_path(self, base_path)
self._api_name = AioHttpApi.normalize_string(self.base_path)

@staticmethod
def normalize_string(string):
return re.sub(r'[^a-zA-Z0-9]', '_', string.strip('/'))

def add_swagger_json(self):
"""
Adds swagger json to {base_path}/swagger.json
"""
logger.debug('Adding swagger.json: %s/swagger.json', self.base_path)
self.subapp.router.add_route(
'GET',
'/swagger.json',
self._get_swagger_json
)

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

def add_swagger_ui(self):
"""
Adds swagger ui to {base_path}/ui/
"""
console_ui_path = self.options.openapi_console_ui_path.strip().rstrip('/')
logger.debug('Adding swagger-ui: %s%s/',
self.base_path,
console_ui_path)

for path in (
console_ui_path,
console_ui_path + '/',
console_ui_path + '/index.html',
):
self.subapp.router.add_route(
'GET',
path,
self._get_swagger_ui_home
)

self.subapp.router.add_static(
console_ui_path + '/',
path=str(self.options.openapi_console_ui_from_dir),
name='swagger_ui_static'
)

@aiohttp_jinja2.template('index.html')
@asyncio.coroutine
def _get_swagger_ui_home(self, req):
return {'api_url': self.base_path}

def add_auth_on_not_found(self, security, security_definitions):
"""
Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass.
"""
logger.debug('Adding path not found authentication')
not_found_error = AuthErrorHandler(
self, _HttpNotFoundError(),
security=security,
security_definitions=security_definitions
)
endpoint_name = "{}_not_found".format(self._api_name)
self.subapp.router.add_route(
'*',
'/{not_found_path}',
not_found_error.function,
name=endpoint_name
)

def _add_operation_internal(self, method, path, operation):
method = method.upper()
operation_id = operation.operation_id or path

logger.debug('... Adding %s -> %s', method, operation_id,
extra=vars(operation))

handler = operation.function
endpoint_name = '{}_{}_{}'.format(
self._api_name,
AioHttpApi.normalize_string(path),
method.lower()
)
self.subapp.router.add_route(
method, path, handler, name=endpoint_name
)

if not path.endswith('/'):
self.subapp.router.add_route(
method, path + '/', handler, name=endpoint_name + '_'
)

@classmethod
@asyncio.coroutine
def get_request(cls, req):
"""Convert aiohttp request to connexion

:param req: instance of aiohttp.web.Request
:return: connexion request instance
:rtype: ConnexionRequest
"""
url = str(req.url)
logger.debug('Getting data and status code',
extra={'has_body': req.has_body, 'url': url})

query = {k: ','.join(v) for k, v in parse_qs(req.rel_url.query_string).items()}
headers = {k.decode(): v.decode() for k, v in req.raw_headers}
body = None
if req.can_read_body:
body = yield from req.read()

return ConnexionRequest(url=url,
method=req.method.lower(),
path_params=dict(req.match_info),
query=query,
headers=headers,
body=body,
json_getter=lambda: cls.jsonifier.loads(body),
files={})

@classmethod
@asyncio.coroutine
def get_response(cls, response, mimetype=None, request=None):
"""Get response.
This method is used in the lifecycle decorators

:rtype: aiohttp.web.Response
"""
while asyncio.iscoroutine(response):
response = yield from response

url = str(request.url) if request else ''

logger.debug('Getting data and status code',
extra={
'data': response,
'url': url
})

if isinstance(response, ConnexionResponse):
response = cls._get_aiohttp_response_from_connexion(response, mimetype)

logger.debug('Got data and status code (%d)',
response.status, extra={'data': response.body, 'url': url})

return response

@classmethod
def get_connexion_response(cls, response):
return ConnexionResponse(
status_code=response.status,
mimetype=response.content_type,
content_type=response.content_type,
headers=response.headers,
body=response.body
)

@classmethod
def _get_aiohttp_response_from_connexion(cls, response, mimetype):
content_type = response.content_type if response.content_type else \
Copy link
Contributor

Choose a reason for hiding this comment

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

I think such complex conditions should be written as a regular multiline if else block

response.mimetype if response.mimetype else mimetype

body = cls._cast_body(response.body, content_type)

return web.Response(
status=response.status_code,
content_type=content_type,
headers=response.headers,
body=body
)

@classmethod
def _cast_body(cls, body, content_type):
if not isinstance(body, bytes):
if is_json_mimetype(content_type):
return json.dumps(body).encode()

elif isinstance(body, str):
return body.encode()

else:
return str(body).encode()
else:
return body

@classmethod
def _set_jsonifier(cls):
cls.jsonifier = Jsonifier(json)


class _HttpNotFoundError(HTTPNotFound):
def __init__(self):
self.name = 'Not Found'
self.description = (
'The requested URL was not found on the server. '
'If you entered the URL manually please check your spelling and '
'try again.'
)
self.code = type(self).status_code
self.empty_body = True

HTTPNotFound.__init__(self, reason=self.name)
Loading