-
-
Notifications
You must be signed in to change notification settings - Fork 773
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
Conversation
Oh, wow, thanks! |
There was a problem hiding this 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.
connexion/__init__.py
Outdated
@@ -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 |
There was a problem hiding this comment.
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
connexion/apis/aiohttp_api.py
Outdated
response = yield from response | ||
|
||
if request: | ||
url = str(request.url) |
There was a problem hiding this comment.
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 ''
connexion/apis/aiohttp_api.py
Outdated
ujson.dumps = partial(ujson.dumps, escape_forward_slashes=True) | ||
|
||
except ImportError: # pragma: no cover | ||
import json as ujson |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree!
connexion/apis/aiohttp_api.py
Outdated
class AioHttpApi(AbstractAPI): | ||
|
||
def __init__(self, *args, **kwargs): | ||
self.aiohttp_api = AioHtppApplication( |
There was a problem hiding this comment.
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
connexion/__init__.py
Outdated
@@ -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 |
There was a problem hiding this comment.
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
General note: the PR lacks some docs about how to plug-in aiohttp server backend, plus |
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". And about your comments I will update the PR |
2bf4e04
to
f6b9447
Compare
There was a problem hiding this 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
connexion/apis/aiohttp_api.py
Outdated
extra=vars(operation)) | ||
|
||
handler = operation.function | ||
endpoint_name = '{}_{}'.format(self._api_name, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
connexion/apis/aiohttp_api.py
Outdated
|
||
def _add_operation_internal(self, method, path, operation): | ||
method = method.upper() | ||
operation_id = \ |
There was a problem hiding this comment.
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)
@prawn-cake |
1fff7ea
to
a0184e6
Compare
There was a problem hiding this 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant import?
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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 =)
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 \ |
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
Also found aiohttp level issue with the So basically this will fail app = connexion.AioHttpApp(__name__)
app.add_api('swagger.yaml', base_path='/')
app.run(port=8080) with
This works ok ...
app.add_api('swagger.yaml', base_path='/api') |
I also created simple project to test your changes and try to use aiohttp-backed connexion service. 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. |
@prawn-cake and about the docs issues |
|
||
app = connexion.AioHttpApp(__name__, specification_dir='swagger/') | ||
app.run(port=8080) | ||
|
There was a problem hiding this comment.
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
Regarding the base_path. At least we could intercept that exception and re-raise it with the more meaningul message in the Added comment in the README |
cfe55f0
to
8f4ff03
Compare
@prawn-cake, I think I did the requested changes. Can you take a look? |
Can we bump aiohttp version to 2.3.10? The rest of the stuff looks good to me |
@prawn-cake can be the aiohttp 3.0.1? |
3.0.1 has too many changes and probably backward incompatible. At least this one bugs me:
|
661b0dd
to
afbd478
Compare
👍 |
@prawn-cake |
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) |
@haneefkassam almost correct, this allows to switch the backend from flask to aiohttp, basically to make connexion async |
@dutradda let me check regarding merging, sorry for not getting back earlier. |
@hjacobs thank you! |
@prawn-cake Would this also mean swapping the backend from flask to sanic is much more trivial? |
@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. |
@haneefkassam |
@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! |
@dutradda do you have some examples for connexion + aiohttp?
Would be interesting how websockets would be represented in swagger.yaml. thx in advance! |
Has anyone managed to get a websocket example working? |
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