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

Fix RuntimeError exception from **application/x-www-form-urlencoded** (see also PR# 1222) #1348

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b131e21
Added unit tests to demonstrate the problems of https://github.com/za…
ddurham2 Apr 22, 2020
2af3c48
now splitting out multipart POSTs into files[] and form[], handling d…
ddurham2 Apr 22, 2020
6ba7927
rewrote how operations/openapi.py::_get_body_argument() works to bett…
ddurham2 Apr 22, 2020
c277980
Adding unit tests to improve code converage test
ddurham2 Apr 23, 2020
41e03cd
Merge remote-tracking branch 'upstream/master' into fix_975-renewed
ddurham2 May 20, 2020
5b69e90
post merge fixes - using 'async' keyword now in new unit test file
ddurham2 May 20, 2020
e214f45
unit test improvements -- now testing the contents of the files we up…
ddurham2 May 22, 2020
1628d36
making some code a bit clearer regarding duplicate names of file subm…
ddurham2 May 22, 2020
13488a5
Merge branch 'master' of https://github.com/zalando/connexion into fi…
ddurham2 Jul 22, 2020
d1345f5
Patched up 2.7.0 version with validation.py repair of request.body pa…
Mar 10, 2021
a7f8858
US ASCII is a safer encoding for the body parsing
Mar 11, 2021
92c28c1
Revert to utf-8 as the default
Mar 11, 2021
f41436f
Merge branch 'master' into fix-urlencoded-body-parameter-parsing
RichardBruskiewich Mar 11, 2021
0b8c861
parse_body_parameters is a static function
Mar 11, 2021
9524238
Merge branch 'fix-urlencoded-body-parameter-parsing' of https://githu…
Mar 11, 2021
91ea2f1
Applied https://github.com/zalando/connexion/issues/975 fix commit #9…
Mar 11, 2021
1d54bb1
Tag your version with a local STAR tag
Mar 11, 2021
b091fb1
Applying patch https://github.com/zalando/connexion/pull/954
Mar 11, 2021
114bdf4
trench warefare: just another small iteration that makes sense?
Mar 11, 2021
c7a01e6
set 'body' default to an empty byte string
Mar 11, 2021
b7aefc3
Merge branch 'jjdunham2/fix_975-renewed' into fix-urlencoded-body-par…
Mar 11, 2021
150921b
missing string type in TYPE_MAP?
Mar 12, 2021
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ htmlcov/
.vscode/
venv/
src/
venv/
2 changes: 1 addition & 1 deletion connexion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ def _required_lib(exc, *args, **kwargs):
AioHttpApp = _aiohttp_not_installed_error

# This version is replaced during release process.
__version__ = '2020.0.dev1'
__version__ = '2020.0.dev1-STAR-Informatics-Patched'
52 changes: 47 additions & 5 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import aiohttp_jinja2
import jinja2
from aiohttp import web
from aiohttp.web_request import FileField
from aiohttp.web_exceptions import HTTPNotFound, HTTPPermanentRedirect
from aiohttp.web_middlewares import normalize_path_middleware
from connexion.apis.abstract import AbstractAPI
Expand Down Expand Up @@ -299,23 +300,64 @@ async 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})

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': req.content_type,
'url': url,
},
)

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

# if request is not multipart, `post_data` will be empty dict
# and stream will not be consumed
post_data = await 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):
if k in files:
# if multiple files arrive under the same name in the
# request, downstream requires that we put them all into
# a list under the same key in the files dict.
if isinstance(files[k], list):
files[k].append(v)
else:
files[k] = [files[k], v]
else:
files[k] = v
else:
# 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 = await 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={},
form=form,
files=files,
context=req)

@classmethod
Expand Down
23 changes: 21 additions & 2 deletions connexion/decorators/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import copy
import functools
import logging
from urllib.parse import parse_qs


import pkg_resources
from jsonschema import Draft4Validator, ValidationError, draft4_format_checker
Expand All @@ -23,7 +25,8 @@
'integer': int,
'number': float,
'boolean': boolean,
'object': dict
'object': dict,
'string': str
}


Expand Down Expand Up @@ -100,6 +103,20 @@ def validate_parameter_list(request_params, spec_params):
return request_params.difference(spec_params)


def parse_body_parameters(body, encoding="utf-8"):
parsed_body = parse_qs(body.decode(encoding))
# Flatten the parameters and take only the first value
params = dict()
for key, value in parsed_body.items():
valen = len(value)
if valen:
if valen <= 1:
params[key] = value[0] # flatten single element lists
else:
params[key] = value # leave multi-valued lists intact
return params


class RequestBodyValidator(object):

def __init__(self, schema, consumes, api, is_null_value_valid=False, validator=None,
Expand Down Expand Up @@ -159,7 +176,9 @@ def wrapper(request):
if data is not None or not self.has_default:
self.validate_schema(data, request.url)
elif self.consumes[0] in FORM_CONTENT_TYPES:
data = dict(request.form.items()) or (request.body if len(request.body) > 0 else {})
# TODO: parse_body_parameters() should probably be given an explicit encoding from the request
data = dict(request.form.items()) or \
(parse_body_parameters(request.body) if len(request.body) > 0 else {})
data.update(dict.fromkeys(request.files, '')) # validator expects string..
logger.debug('%s validating schema...', request.url)

Expand Down
73 changes: 58 additions & 15 deletions connexion/operations/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,36 +268,79 @@ def body_definition(self):
return {}

def _get_body_argument(self, body, arguments, has_kwargs, sanitize):
x_body_name = sanitize(self.body_schema.get('x-body-name', 'body'))
if len(arguments) <= 0 and not has_kwargs:
return {}

x_body_name_default = 'body'
x_body_name_explicit = True
x_body_name = sanitize(self.body_schema.get('x-body-name', ''))
if not x_body_name:
x_body_name_explicit = False
x_body_name = x_body_name_default

# if the body came in null, and the schema says it can be null, we decide
# to include no value for the body argument, rather than the default body
if is_nullable(self.body_schema) and is_null(body):
return {x_body_name: None}
if x_body_name in arguments or has_kwargs:
return {x_body_name: None}
return {}

# now determine the actual value for the body (whether it came in or is default)
default_body = self.body_schema.get('default', {})
body_props = {k: {"schema": v} for k, v
body_props = {sanitize(k): {"schema": v} for k, v
in self.body_schema.get("properties", {}).items()}

# by OpenAPI specification `additionalProperties` defaults to `true`
# see: https://github.com/OAI/OpenAPI-Specification/blame/3.0.2/versions/3.0.2.md#L2305
additional_props = self.body_schema.get("additionalProperties", True)

if body is None:
body = deepcopy(default_body)

# if the body isn't even an object, then none of the concerns below matter
if self.body_schema.get("type") != "object":
if x_body_name in arguments or has_kwargs:
return {x_body_name: body}
return {}

body_arg = deepcopy(default_body)
body_arg.update(body or {})
# by OpenAPI specification `additionalProperties` defaults to `true`
# see: https://github.com/OAI/OpenAPI-Specification/blame/3.0.2/versions/3.0.2.md#L2305
additional_props = self.body_schema.get("additionalProperties", True)

res = {}
if body_props or additional_props:
res = self._get_typed_body_values(body_arg, body_props, additional_props)
# supply the initial defaults and convert all values to the proper types by schema
x = deepcopy(default_body)
x.update(body or {})
converted_body = self._get_typed_body_values(x, body_props, additional_props)

# NOTE:
# Handlers could have a single argument to receive the whole body or they
# could have individual arguments to receive all the body's parameters, or
# they may have a **kwargs, arguments to receive anything. So, the question
# arises that if kwargs is given, do we pass to the handler a single body
# argument, or the broken out arguments, or both?
#
# #1 If 'x-body-arg' is explicitly given and it exists in [arguments], then the
# body, as a whole, will be passed to the handler with that name. STOP.
#
# #2 If kwargs is given, then we don't know what the handler cares about, so we
# pass the body as a whole as an argument named, 'body', along with the
# individual body properties. STOP.
#
# #3 Else, we pass the body's individual properties which exist in [arguments].
#
# #4 Finally, if that resulting argument list is empty, then we include an argument
# named 'body' to the handler, but only if 'body' exists in [arguments]

if x_body_name_explicit and x_body_name in arguments: #1
return {x_body_name: converted_body}

if has_kwargs: #2
converted_body[x_body_name_default] = copy(converted_body) # copy just to avoid circular ref
return converted_body

r = {k: converted_body[k] for k in converted_body if k in arguments} #3

if len(r) <= 0 and x_body_name_default in arguments: #4
r[x_body_name_default] = converted_body

return r

if x_body_name in arguments or has_kwargs:
return {x_body_name: res}
return {}

def _get_typed_body_values(self, body_arg, body_props, additional_props):
"""
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def read_version(package):
swagger_ui_require = 'swagger-ui-bundle>=0.0.2'
flask_require = 'flask>=1.0.4'
aiohttp_require = [
'aiohttp>=2.3.10',
'aiohttp>=2.3.10,<4',
'aiohttp-jinja2>=0.14.0'
]

Expand Down
118 changes: 118 additions & 0 deletions tests/aiohttp/test_aiohttp_multipart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import os

import aiohttp
import pytest
from pathlib import Path

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


async def test_single_file_upload(aiohttp_app, aiohttp_client):
app_client = await aiohttp_client(aiohttp_app.app)

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

data = await resp.json()
assert resp.status == 200
assert data['fileName'] == f'{__name__}.py'
assert data['content'] == Path(__file__).read_bytes().decode('utf8')


async def test_many_files_upload(aiohttp_app, aiohttp_client):
app_client = await 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 sorted(os.listdir(dir_name)) if file_name.endswith('.py')
]

form_data = aiohttp.FormData(fields=files_field)

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

data = await resp.json()

assert resp.status == 200
assert data['filesCount'] == len(files_field)
assert data['contents'] == [
Path(f'{dir_name}/{file_name}').read_bytes().decode('utf8') \
for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py')
]


async def test_mixed_multipart_single_file(aiohttp_app, aiohttp_client):
app_client = await 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 = await app_client.post(
'/v1.0/mixed_single_file',
data=form_data(),
)

data = await resp.json()

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



async def test_mixed_multipart_many_files(aiohttp_app, aiohttp_client):
app_client = await 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 sorted(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 = await app_client.post(
'/v1.0/mixed_many_files',
data=form_data(),
)

data = await 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)
assert data['contents'] == [
Path(f'{dir_name}/{file_name}').read_bytes().decode('utf8') \
for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py')
]
13 changes: 13 additions & 0 deletions tests/api/test_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ def test_nullable_parameter(simple_app):
resp = app_client.put('/v1.0/nullable-parameters', data="None", headers=headers)
assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None'

resp = app_client.put('/v1.0/nullable-parameters-noargs', data="None", headers=headers)
assert json.loads(resp.data.decode('utf-8', 'replace')) == 'hello'


def test_args_kwargs(simple_app):
app_client = simple_app.app.test_client()
Expand All @@ -363,6 +366,16 @@ def test_args_kwargs(simple_app):
assert resp.status_code == 200
assert json.loads(resp.data.decode('utf-8', 'replace')) == {'foo': 'a'}

if simple_app._spec_file == 'openapi.yaml':
body = { 'foo': 'a', 'bar': 'b' }
resp = app_client.post(
'/v1.0/body-params-as-kwargs',
data=json.dumps(body),
headers={'Content-Type': 'application/json'})
assert resp.status_code == 200
# having only kwargs and no explicit x-body-name, the handler would have been passed 'body' and the individual params from body
assert json.loads(resp.data.decode('utf-8', 'replace')) == {'body': {'foo': 'a', 'bar': 'b'}, 'foo': 'a', 'bar': 'b'}


def test_param_sanitization(simple_app):
app_client = simple_app.app.test_client()
Expand Down
Loading