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

[aiohttp] Multipart handling #987

Closed
wants to merge 11 commits into from
Closed
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
51 changes: 45 additions & 6 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,9 @@ def redirect(request):
@aiohttp_jinja2.template('index.j2')
@asyncio.coroutine
def _get_swagger_ui_home(self, req):
return {'openapi_spec_url': (self.base_path +
self.options.openapi_spec_path)}
return {'openapi_spec_url': (
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unrelated but flake8 was unhappy about it.

self.base_path + self.options.openapi_spec_path,
)}

def add_auth_on_not_found(self, security, security_definitions):
"""
Expand Down Expand Up @@ -264,13 +265,50 @@ def get_request(cls, req):
:rtype: ConnexionRequest
"""
url = str(req.url)
logger.debug('Getting data and status code',
extra={'has_body': req.has_body, 'url': url})
content_type = req.content_type

logger.debug(
'Getting data and status code',
extra={
# has_body | can_read_body report if
# body has been read or not
# body_exists refers to underlying stream of data
'body_exists': req.body_exists,
'can_read_body': req.can_read_body,
'content_type': content_type,
'url': url,
},
)

query = parse_qs(req.rel_url.query_string)
headers = req.headers
body = None
if req.body_exists:

# if request is not multipart, `data` will be empty dict
# and stream will not be consumed
post_data = yield from req.post()

# set those up beforehand, they are needed anyway
files = {}
form = {}

if post_data:
logger.debug('Reading multipart data from request')
for k, v in post_data.items():
if isinstance(v, web.FileField) and k not in files:
files[k] = v
elif isinstance(v, web.FileField) and k in files:
try:
Copy link
Contributor

Choose a reason for hiding this comment

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

Any concerns of using collections.defaultdict instead of handling an exception?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TBH I have doubts if that is correct. It works for many files but if I mix normal fields and files everything goes wrong.

Also form is processed in weird way, I will post details łatwe for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting thing. I noticed that if you try to submit multiple fields for Flask it will ignore them and take just the first value.
I might want to create an issue on its own for that :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

FYI: #992

files[k].append(v)
except AttributeError:
files[k] = [files[k], v]
elif not isinstance(v, web.FileField):
# put normal fields as an array, that's how werkzeug does that for Flask
# and that's what Connexion expects in its processing functions
form[k] = [v]
body = b''
else:
logger.debug('Reading data from request')
body = yield from req.read()

return ConnexionRequest(url=url,
Expand All @@ -280,7 +318,8 @@ def get_request(cls, req):
headers=headers,
body=body,
json_getter=lambda: cls.jsonifier.loads(body),
files={},
form=form,
files=files,
context=req)

@classmethod
Expand Down
6 changes: 3 additions & 3 deletions connexion/http_facts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FORM_CONTENT_TYPES = [
FORM_CONTENT_TYPES = (
'application/x-www-form-urlencoded',
'multipart/form-data'
]
'multipart/form-data',
)

METHODS = set([
"get",
Expand Down
2 changes: 1 addition & 1 deletion connexion/operations/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize):

if x_body_name in arguments or has_kwargs:
Copy link
Contributor

@ddurham2 ddurham2 Jan 2, 2020

Choose a reason for hiding this comment

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

@kornicameister, you and I are both trying to get aiohttp to work well with connexion. I can't wait until this PR is accepted.

Possibly another thing to add in this area of change:
Can you at all fathom why the 'or has_kwargs' is here??? I'm trying to create decorators for my handlers. The decorators generically pass all args along as **kwargs. However, when **kwargs is present in the handler, then this code kicks in and rather than returning parameters from the body broken out, it returns the body itself as a single arg. This makes much more difficult to create decorators.

There's a similar condition (but not problematic for me) also on line 263 above.

The original change doesn't describe why. It's just "connexion 2.0" in the blame.

I would propose removing the 'or has_kwargs' so that parameters are the same whether you break them out as named arguments in the handler or accept any named argument in the handler. Thoughts?

For now I've worked around it by expanding 'body' into the kwargs at the beginning of the decorator's wrapper, but I don't know if that handles all cases, and it just seems inconsistent of connexion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ddurham2 I have tried to untagle the code in different parts of connexion without too much luck. I am afraid I can't help you. Due to various issues, like this one, we're forced to move away from connexion thus it is no longer my top priority to invest my time here.

Although, I am tracking what's going on, and my original opinion on state of affairs in connexion had not changed. Without strong API contract enforced & required by connexion it will be hard to make connextion, Flask and aiohttp implementation compliant.
And, since that contract is not in place, it is close to impossible or, at the very least, really hard to ensure proper implementation.
One example of such situation is handling this multipart, I believe I've already mentioned that, where files from request are assumed to be dist-ish structure and as long as dict-ish structure is returned from Flask and aiohttp there little thing that makes a difference and that's implementation of that dict-ish being. TBH & AFAIR connexion requires that being to just be iterable or have get method which is rather poor expectation (all hail strong typed languages where weird stuff like that has lower chance to occur :/ ).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it were up to me, I'd have ditched that PR and focus on work to prepare strong internal API contract toward making aiohttp and Flask plugins.

Copy link
Contributor

Choose a reason for hiding this comment

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

Did you find an alternative to connexion?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are alternatives but concept is a mirror reflection, essentially it is code that comes first.
I am spending my time playing around with strongly typed self-creation :) now, it is available in my repositories ( axion ). It's no place to talk about it, but if you decide to check it out, let me know. It's mess from place to place but maybe sometime later I will clean it all up.

return {x_body_name: res}
return {}
return res

def _sanitize_body_argument(self, body_arg, body_props, additional_props, sanitize):
"""
Expand Down
105 changes: 105 additions & 0 deletions tests/aiohttp/test_aiohttp_multipart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import asyncio
import os

import aiohttp
import pytest

from connexion import AioHttpApp

try:
import ujson as json
except ImportError:
import json


@pytest.fixture
def aiohttp_app(aiohttp_api_spec_dir):
app = AioHttpApp(__name__, port=5001,
specification_dir=aiohttp_api_spec_dir,
debug=True)
app.add_api(
'openapi_multipart.yaml',
validate_responses=True,
strict_validation=True,
pythonic_params=True,
pass_context_arg_name='request_ctx',
)
return app


@asyncio.coroutine
def test_single_file_upload(aiohttp_app, aiohttp_client):
app_client = yield from aiohttp_client(aiohttp_app.app)

resp = yield from app_client.post(
'/v1.0/upload_file',
data=aiohttp.FormData(fields=[('funky_funky', open(__file__, 'rb'))])(),
)

data = yield from resp.json()
assert resp.status == 200
assert data['fileName'] == f'{__name__}.py'


@asyncio.coroutine
def test_many_files_upload(aiohttp_app, aiohttp_client):
app_client = yield from aiohttp_client(aiohttp_app.app)

dir_name = os.path.dirname(__file__)
files_field = [('files', open(f'{dir_name}/{file_name}', 'rb')) for file_name in os.listdir(dir_name) if file_name.endswith('.py')]

form_data = aiohttp.FormData(fields=files_field)

resp = yield from app_client.post(
'/v1.0/upload_files',
data=form_data(),
)

data = yield from resp.json()

assert resp.status == 200
assert data['filesCount'] == len(files_field)


@asyncio.coroutine
def test_mixed_multipart_single_file(aiohttp_app, aiohttp_client):
app_client = yield from aiohttp_client(aiohttp_app.app)

form_data = aiohttp.FormData()
form_data.add_field('dirName', os.path.dirname(__file__))
form_data.add_field('funky_funky', open(__file__, 'rb'))

resp = yield from app_client.post(
'/v1.0/mixed_single_file',
data=form_data(),
)

data = yield from resp.json()

assert resp.status == 200
assert data['dirName'] == os.path.dirname(__file__)
assert data['fileName'] == f'{__name__}.py'


@asyncio.coroutine
def test_mixed_multipart_many_files(aiohttp_app, aiohttp_client):
app_client = yield from aiohttp_client(aiohttp_app.app)

dir_name = os.path.dirname(__file__)
files_field = [('files', open(f'{dir_name}/{file_name}', 'rb')) for file_name in os.listdir(dir_name) if file_name.endswith('.py')]

form_data = aiohttp.FormData(fields=files_field)
form_data.add_field('dirName', os.path.dirname(__file__))
form_data.add_field('testCount', str(len(files_field)))

resp = yield from app_client.post(
'/v1.0/mixed_many_files',
data=form_data(),
)

data = yield from resp.json()

assert resp.status == 200
assert data['dirName'] == os.path.dirname(__file__)
assert data['testCount'] == len(files_field)
assert data['filesCount'] == len(files_field)
35 changes: 35 additions & 0 deletions tests/fakeapi/aiohttp_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,38 @@ def aiohttp_users_post(user):
user['id'] = len(USERS) + 1
USERS.append(user)
return aiohttp.web.json_response(data=USERS[-1], status=201)


@asyncio.coroutine
def aiohttp_multipart_single_file(funky_funky):
return aiohttp.web.json_response(
data={'fileName': funky_funky.filename},
)


@asyncio.coroutine
def aiohttp_multipart_many_files(files):
return aiohttp.web.json_response(
data={'filesCount': len(files)},
)


@asyncio.coroutine
def aiohttp_multipart_mixed_single_file(dir_name, funky_funky):
return aiohttp.web.json_response(
data={
'dirName': dir_name,
'fileName': funky_funky.filename,
},
)


@asyncio.coroutine
def aiohttp_multipart_mixed_many_files(dir_name, test_count, files):
return aiohttp.web.json_response(
data={
'filesCount': len(files),
'dirName': dir_name,
'testCount': test_count,
},
)
132 changes: 132 additions & 0 deletions tests/fixtures/aiohttp/openapi_multipart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
openapi: 3.0.0
servers:
- url: /v1.0
info:
title: "{{title}}"
version: "1.0"
paths:
"/mixed_single_file":
post:
summary: Reads multipart data
description: Handles multipart data reading
operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_mixed_single_file
responses:
"200":
description: OK response
content:
'application/json':
schema:
type: object
properties:
dirName:
type: string
fileName:
type: string
default:
description: unexpected error
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
dirName:
type: string
funky_funky:
type: string
format: binary
"/mixed_many_files":
post:
summary: Reads multipart data
description: Handles multipart data reading
operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_mixed_many_files
responses:
"200":
description: OK response
content:
'application/json':
schema:
type: object
properties:
dirName:
type: string
testCount:
type: number
filesCount:
type: number
default:
description: unexpected error
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
dirName:
type: string
testCount:
type: number
files:
type: array
items:
type: string
format: binary
"/upload_file":
post:
summary: Uploads single file
description: Handles multipart file upload.
operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_single_file
responses:
"200":
description: OK response
content:
'application/json':
schema:
type: object
properties:
fileName:
type: string
default:
description: unexpected error
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
funky_funky:
type: string
format: binary
"/upload_files":
post:
summary: Uploads many files
description: Handles multipart file upload.
operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_many_files
responses:
"200":
description: OK response
content:
'application/json':
schema:
type: object
properties:
filesCount:
type: number
default:
description: unexpected error
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: array
items:
type: string
format: binary