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
Merged

Added aiohttp support #530

merged 13 commits into from
Apr 9, 2018

Conversation

dutradda
Copy link
Contributor

Hello Guys, I implemented an App and Api classes supporting aiohttp framework. It is fully tested.
I had did some modifications on the base code as well.
Please, take a look on it

@dutradda dutradda changed the title Adde aiohttp support Added aiohttp support Oct 19, 2017
@coveralls
Copy link

coveralls commented Oct 19, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling 30b7528 on dutradda:aiohttp_support into f846126 on zalando:master.

@hjacobs
Copy link
Contributor

hjacobs commented Oct 19, 2017

Oh, wow, thanks!

Copy link
Contributor

@prawn-cake prawn-cake left a comment

Choose a reason for hiding this comment

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

Hi! Thanks for the PR!
Overall it looks promising. I'm still reviewing the code. Meantime sending some suggestion to improve the code.

@@ -26,5 +27,21 @@ def _required_lib(exec_info, *args, **kwargs):
App = FlaskApp
Api = FlaskApi

if sys.version_info[0:2] >= (3, 4): # 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.

I guess you don't need to slice version info

response = yield from response

if request:
url = str(request.url)
Copy link
Contributor

@prawn-cake prawn-cake Oct 21, 2017

Choose a reason for hiding this comment

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

More concise version: url = str(request.url) if request else ''

ujson.dumps = partial(ujson.dumps, escape_forward_slashes=True)

except ImportError: # pragma: no cover
import json as ujson
Copy link
Contributor

Choose a reason for hiding this comment

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

To me it feels that any 3rd party json parsers should mimic the standard json and not vice-versa, i.e

try:
    import ujson as json
except ImportError:
    import json
...
json.dumps(...)

We don't want to be coupled with any particular implementation using different names

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree!

class AioHttpApi(AbstractAPI):

def __init__(self, *args, **kwargs):
self.aiohttp_api = AioHtppApplication(
Copy link
Contributor

Choose a reason for hiding this comment

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

This is quite confusing. It's an app instance in fact, not an api. self.app = AnyAppImpl(...) would be better

@@ -26,5 +27,21 @@ def _required_lib(exec_info, *args, **kwargs):
App = FlaskApp
Api = FlaskApi

if sys.version_info[0:2] >= (3, 4): # pragma: no cover
try:
from .apis.aiohttp_api import AioHttpApi
Copy link
Contributor

Choose a reason for hiding this comment

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

The code above does almost exactly the same, except import paths. Let's refactor it to not repeat yourself.

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 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())

    FlaskApi = _flask_not_installed_error
    FlaskApp = _flask_not_installed_error

...

if sys.version_info[0:2] >= (3, 4): # pragma: no cover
    try:
        from .apis.aiohttp_api import AioHttpApi
        from .apps.aiohttp_app import AioHttpApp
    except ImportError as e:
        import six
        import functools

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

        _aiohttp_not_installed_error = functools.partial(_required_lib, sys.exc_info())

        AioHttpApi = _aiohttp_not_installed_error
        AioHttpApp = _aiohttp_not_installed_error

I see it as

# We can make this as a high-order function and reuse
def not_installed_error():
    import six
    import functools

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

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

...

try:
...
except ImportError:
    WhateverApi = not_installed_error()

This will make it more readable

@prawn-cake
Copy link
Contributor

General note: the PR lacks some docs about how to plug-in aiohttp server backend, plus cli.py need to be updated to support it as well

@dutradda
Copy link
Contributor Author

dutradda commented Oct 23, 2017

Hello!

@prawn-cake the CLI was already updated. see here

I don't know what you means with "how to plug-in aiohttp server backend".
It is how to implement aiohttp handlers? Or is how to run an aiohttp server? Because the AioHttpApp.run method runs the aiohttp server. What exact I must to document better?

And about your comments I will update the PR

@coveralls
Copy link

coveralls commented Oct 23, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling 8c17d19 on dutradda:aiohttp_support into f846126 on zalando:master.

@coveralls
Copy link

coveralls commented Oct 23, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling 2bf4e04 on dutradda:aiohttp_support into f846126 on zalando:master.

@spec-first spec-first deleted a comment Oct 23, 2017
@spec-first spec-first deleted a comment Oct 23, 2017
@coveralls
Copy link

coveralls commented Oct 23, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling f6b9447 on dutradda:aiohttp_support into f846126 on zalando:master.

@spec-first spec-first deleted a comment Oct 23, 2017
Copy link
Contributor

@prawn-cake prawn-cake left a comment

Choose a reason for hiding this comment

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

@dutradda
Yea. About cli.py. It's a bit tricky. It's not visible in the PR.
But there is an option --wsgi-server which sets it to 'flask' by default and no aiohttp option is available. AioHttpApp.run runs if server == 'aiohttp', right?
We can add 'aiohttp' to the choices list, but it would be wrong interpretation because aiohttp doesn't implement WSGI at all.

@hjacobs what do you think?

About documentation. We want let the users know that they now able to pick the app class. So some hello world examples with some aiohttp title would be enough I think.
Currently we have docs here and in the README

extra=vars(operation))

handler = operation.function
endpoint_name = '{}_{}'.format(self._api_name,
Copy link
Contributor

Choose a reason for hiding this comment

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

If the same resource handles multiple methods, e.g

paths:
  /pets/{pet_id}:
    get:
      ...
    put:
      ...
    delete:
      ...

It will crash on add_api(...) call with

  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/connexion/apis/abstract.py", line 242, in add_paths
    self.add_operation(method, path, endpoint, path_parameters)
  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/connexion/apis/abstract.py", line 196, in add_operation
    self._add_operation_internal(method, path, operation)
  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/connexion/apis/aiohttp_api.py", line 155, in _add_operation_internal
    method, path, handler, name=endpoint_name
  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/aiohttp/web_urldispatcher.py", line 881, in add_route
    resource = self.add_resource(path, name=name)
  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/aiohttp/web_urldispatcher.py", line 876, in add_resource
    self.register_resource(resource)
  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/aiohttp/web_urldispatcher.py", line 863, in register_resource
    .format(name, self._named_resources[name]))
ValueError: Duplicate '_pets__pet_id_', already handled by <DynamicResource '_pets__pet_id_'  /pets/{pet_id}

And tests don't cover such cases at all. Good to add it.
The fix can be to add a method name to a resource (endpoint) name

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.


def _add_operation_internal(self, method, path, operation):
method = method.upper()
operation_id = \
Copy link
Contributor

Choose a reason for hiding this comment

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

Another neat trick:
operation_id = operation.operation_id or path gives you the same result (if the left hand expression is evaluated as false then the right side takes precedence)

@dutradda
Copy link
Contributor Author

dutradda commented Nov 9, 2017

@prawn-cake
I did the requested changes.
About the cli I did one option as deprecated, I don't know if what I did is the best solution.
And about the docs what I did is sufficient?

@coveralls
Copy link

coveralls commented Nov 9, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling 742a80d on dutradda:aiohttp_support into f846126 on zalando:master.

@spec-first spec-first deleted a comment Nov 10, 2017
@coveralls
Copy link

coveralls commented Nov 10, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling 1fff7ea on dutradda:aiohttp_support into f846126 on zalando:master.

@coveralls
Copy link

coveralls commented Nov 10, 2017

Coverage Status

Coverage remained the same at 100.0% when pulling a0184e6 on dutradda:aiohttp_support into f846126 on zalando:master.

@spec-first spec-first deleted a comment Nov 10, 2017
Copy link
Contributor

@prawn-cake prawn-cake left a comment

Choose a reason for hiding this comment

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

I haven't run my tests yet, will do it soon.
Commented about some small changes and clarifications.

connexion/cli.py Outdated
@@ -1,6 +1,7 @@
import importlib
import logging
import sys
from itertools import chain
Copy link
Contributor

Choose a reason for hiding this comment

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

Redundant import?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I already remove this line, this code is outdated

@@ -108,6 +133,23 @@ def run(spec_file,

- BASE_MODULE_PATH (optional): filesystem path where the API endpoints handlers are going to be imported from.
"""
if wsgi_server and server:
raise click.BadParameter(
"these options are mutually excludent",
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor typo, I guess "mutually exclusive" is the proper one =)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will fix my typo :)



def validate_wsgi_server_requirements(ctx, param, value):
FLASK_APP = 'flask'
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if I understood purpose of these constants properly.

  • We divide our config into connexion apps, app frameworks and app servers we serve our web-app with, right?
  • Each app framework can be an app server at the same time, i.e serve itself
  • We want to resolve dependencies between app servers and app frameworks (e.g flask can't be run with aiohttp by default although it has 3rd party wsgi adapters)

Just trying to understand why we need so many configs which repeat the same names

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't know if I understand your points, but I will try to explain why I created these variables.

I created the AVAILABLE_SERVERS variable to ensure that the user input is right (but this validation will be done by the app when the App.run method is called). So this validation can be removed as well.

The AVAILABLE_APPS is for mapping the "--app-framework" input with the right class.

The DEFAULT_SERVERS was created because some frameworks has no default server (like the falcon framework, there's no way to run the application directly from falcon, a external wsgi server must be used), but for now the defaults are the same name of the framework, so I can remove this variable too if you want.

Copy link
Contributor

@prawn-cake prawn-cake Nov 14, 2017

Choose a reason for hiding this comment

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

Yea, I just wanted to know what problem you are trying to solve with this, because it's not obvious at first glance =)
No need to remove.

connexion/cli.py Outdated
if value == 'gevent':
try:
import gevent # NOQA
except:
except Exception:
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's be specific here and below and catch ImportError

Copy link
Contributor

Choose a reason for hiding this comment

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

This one was resolved with this commit. Pull latest changes, please.

connexion/cli.py Outdated
fatal_error('gevent library is not installed')
elif value == 'tornado':
try:
import tornado # NOQA
except:
except Exception:
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we add import aiohttp check as well?


@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



def validate_wsgi_server_requirements(ctx, param, value):
FLASK_APP = 'flask'
Copy link
Contributor

@prawn-cake prawn-cake Nov 14, 2017

Choose a reason for hiding this comment

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

Yea, I just wanted to know what problem you are trying to solve with this, because it's not obvious at first glance =)
No need to remove.

)
raise click.UsageError(message)

if app_framework == AIOHTTP_APP:
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar checks are done in the validate_server_requirements. Is it enough to delegate this check to validate_server_requirements (add aiohttp check there) and remove it from here?

Copy link
Contributor Author

@dutradda dutradda Nov 21, 2017

Choose a reason for hiding this comment

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

I did this check here because if a user input is "--app-framework=flask --server=aiohttp" the error that will raise is "invalid server for 'flask'".

But, if the check is done on "validate_server_requirements" the error that will raise is "aiohttp not found".

But I can move this check to the "validate_server_requirements" function if you prefer

Copy link
Contributor

Choose a reason for hiding this comment

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

The code generates the error you are talking about should be run first. So it shouldn't affect app-framework - server validation.
The issue I faced is when you move if app_framework == AIOHTTP_APP check to the validate_server_requirements and attach that callback for the run cli method, it eats up app_framework value and misbehave after.

The reason is that callback returns None after the check. Which doesn't seem right.

I updated it to this (always return the value in case of no failures) and this should work.

def validate_server_requirements(ctx, param, value):
    if value == 'gevent':
        try:
            import gevent  # NOQA
        except Exception:
            fatal_error('gevent library is not installed')
    elif value == 'tornado':
        try:
            import tornado  # NOQA
        except Exception:
            fatal_error('tornado library is not installed')
    elif value == 'aiohttp':
        try:
            import aiohttp
        except ImportError:
            fatal_error('aiohttp library is not installed')
    return value

Could you check it for yourself?

@prawn-cake
Copy link
Contributor

prawn-cake commented Nov 15, 2017

Also found aiohttp level issue with the base_path when it adds a subapp.

So basically this will fail

app = connexion.AioHttpApp(__name__)
app.add_api('swagger.yaml', base_path='/')
app.run(port=8080)

with

  File "/home/maxi/myPython/tryConnexion/app.py", line 53, in <module>
    app.add_api('swagger.yaml', base_path='/')
  File "/home/maxi/myPython/connexion/connexion/apps/aiohttp_app.py", line 43, in add_api
    self.app.add_subapp(api.base_path, api.subapp)
  File "/home/maxi/myPython/tryConnexion/.env/lib/python3.5/site-packages/aiohttp/web.py", line 186, in add_subapp
    raise ValueError("Prefix cannot be empty")
ValueError: Prefix cannot be empty

This works ok

...
app.add_api('swagger.yaml', base_path='/api')

@prawn-cake
Copy link
Contributor

I also created simple project to test your changes and try to use aiohttp-backed connexion service.
I had to struggle a bit with the handlers, took me some time to debug errors. Initially I expected that it would work transparently if I switch the app and turn my handlers into coroutines, not really. Errors it spits out are not obvious at all.

Thought of may be we could extend our hello world example with the proper aiohttp handler or at least make a note and point to the aiohttp handlers documentation.

@dutradda
Copy link
Contributor Author

@prawn-cake
you suggest some modification to the base_path issue?

and about the docs issues
where are the hello world example that I can modify?


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

@prawn-cake
Copy link
Contributor

Regarding the base_path. At least we could intercept that exception and re-raise it with the more meaningul message in the AioHttpApp.add_api method.
For example "aiohttp doesn't allow to set empty base_path ({base_path}), use non-empty instead, e.g /api"

Added comment in the README

@spec-first spec-first deleted a comment Feb 5, 2018
@spec-first spec-first deleted a comment Feb 5, 2018
@spec-first spec-first deleted a comment Feb 5, 2018
@dutradda
Copy link
Contributor Author

@prawn-cake, I think I did the requested changes. Can you take a look?

@prawn-cake
Copy link
Contributor

Can we bump aiohttp version to 2.3.10? The rest of the stuff looks good to me

@dutradda
Copy link
Contributor Author

@prawn-cake can be the aiohttp 3.0.1?
it is the latest one

@prawn-cake
Copy link
Contributor

3.0.1 has too many changes and probably backward incompatible.

At least this one bugs me:

Minimal supported Python versions are 3.5.3 and 3.6.0

@prawn-cake
Copy link
Contributor

👍

@prawn-cake
Copy link
Contributor

@hjacobs I guess we are done here.
@dutradda Thanks for the great contribution.

@spec-first spec-first deleted a comment Feb 19, 2018
@dutradda
Copy link
Contributor Author

dutradda commented Apr 2, 2018

@prawn-cake
Hello guys, do you know when this PR will be merged to the master?

@haneefkassam
Copy link

This is amazing! (and I'm assuming paves the path to also using uvloop with connexion and flask, and async handler functions, right? please correct me if I'm wrong)

@prawn-cake
Copy link
Contributor

@haneefkassam almost correct, this allows to switch the backend from flask to aiohttp, basically to make connexion async

@hjacobs
Copy link
Contributor

hjacobs commented Apr 9, 2018

@dutradda let me check regarding merging, sorry for not getting back earlier.

@hjacobs hjacobs merged commit afbd478 into spec-first:master Apr 9, 2018
@dutradda
Copy link
Contributor Author

dutradda commented Apr 9, 2018

@hjacobs thank you!

@hjacobs
Copy link
Contributor

hjacobs commented Apr 9, 2018

@haneefkassam
Copy link

@prawn-cake Would this also mean swapping the backend from flask to sanic is much more trivial?

@prawn-cake
Copy link
Contributor

@haneefkassam it's definitely easier since we added api abstraction, but not sure how trivial it is to add sanic, need to check implementation details.

@dutradda
Copy link
Contributor Author

dutradda commented Apr 10, 2018

@haneefkassam
I alreadly used sanic and I can say that is very easy to plug it on connexion.
But the aiohttp is not good for you?
I switched from sanic to aiohttp because its maturity. And it peforms well too

@haneefkassam
Copy link

@dutradda No no, it's not that aiohttp is not good; I've just seen sanic vs aiohttp go in favour of sanic (without considering replacing the purely python asyncio with uvloop in aiohttp).

I'm not one to be un-appreciative though; I'm loving the fact this PR was merged and love the fact that I now have the option of using python3 async features with connexion!

@roman-telepathy-ai
Copy link

@dutradda do you have some examples for connexion + aiohttp?

  • async get / post
  • async get / post with blocking calls
  • websockets

Would be interesting how websockets would be represented in swagger.yaml.

thx in advance!

@maitham
Copy link

maitham commented Apr 10, 2019

Has anyone managed to get a websocket example working?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants