Skip to content

Commit 54e50f2

Browse files
drummerwollihjacobs
authored andcommitted
handle deepObject and explode: true (#971)
* first implementation draft * gitignore virtualenv * use isinstance instead of type function * fix tests * remove unused function * move object parsing to uri_parsing.py * remove not needed import * only test for OpenAPI * remove not needed import * make it work for other cases again * flake8 fixes * python2.7 fixes * isort fix * address code review comments * remove for loop and address other comments * remove not needed abstract function * move array unnesting into uri_parsing * make nested arrays possible * style fixes * style fixes * test other data types * comment and simplify function * WIP: start additionalProperties test * test additionalProperties * remove uneccessary exception * set default values * set default values also in response * flake8 fixes * fix test * use suggestions from dtkav's branch * fix tests partially * fix tests partially * fix tests * fix tests * add comments for clarity
1 parent 485380d commit 54e50f2

File tree

11 files changed

+231
-3
lines changed

11 files changed

+231
-3
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ htmlcov/
1111
*.swp
1212
.tox/
1313
.idea/
14+
venv/

connexion/decorators/uri_parsing.py

+30
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import abc
33
import functools
44
import logging
5+
import re
56

67
import six
78

9+
from ..utils import create_empty_dict_from_list
810
from .decorator import BaseDecorator
911

1012
logger = logging.getLogger('connexion.decorators.uri_parsing')
@@ -96,8 +98,23 @@ def resolve_params(self, params, _in):
9698
"""
9799
resolved_param = {}
98100
for k, values in params.items():
101+
# extract the dict keys if specified with style: deepObject and explode: true
102+
# according to https://swagger.io/docs/specification/serialization/#query
103+
dict_keys = re.findall(r'\[(\w+)\]', k)
104+
if dict_keys:
105+
k = k.split("[", 1)[0]
106+
param_defn = self.param_defns.get(k)
107+
if param_defn and param_defn.get('style', None) == 'deepObject' and param_defn.get('explode', False):
108+
param_schema = self.param_schemas.get(k)
109+
if isinstance(values, list) and len(values) == 1 and param_schema['type'] != 'array':
110+
values = values[0]
111+
resolved_param.setdefault(k, {})
112+
resolved_param[k].update(create_empty_dict_from_list(dict_keys, {}, values))
113+
continue
114+
99115
param_defn = self.param_defns.get(k)
100116
param_schema = self.param_schemas.get(k)
117+
101118
if not (param_defn or param_schema):
102119
# rely on validation
103120
resolved_param[k] = values
@@ -115,8 +132,21 @@ def resolve_params(self, params, _in):
115132
else:
116133
resolved_param[k] = values[-1]
117134

135+
# set defaults if values have not been set yet
136+
resolved_param = self.set_default_values(resolved_param, self.param_schemas)
137+
118138
return resolved_param
119139

140+
def set_default_values(self, _dict, _properties):
141+
"""set recursively default values in objects/dicts"""
142+
for p_id, property in _properties.items():
143+
if 'default' in property and p_id not in _dict:
144+
_dict[p_id] = property['default']
145+
elif property.get('type', False) == 'object' and 'properties' in property:
146+
_dict.setdefault(p_id, {})
147+
_dict[p_id] = self.set_default_values(_dict[p_id], property['properties'])
148+
return _dict
149+
120150
def __call__(self, function):
121151
"""
122152
:type function: types.FunctionType

connexion/decorators/validation.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
TYPE_MAP = {
2020
'integer': int,
2121
'number': float,
22-
'boolean': boolean
22+
'boolean': boolean,
23+
'object': dict
2324
}
2425

2526

@@ -63,6 +64,21 @@ def make_type(value, type_literal):
6364
converted = v
6465
converted_params.append(converted)
6566
return converted_params
67+
elif param_type == 'object':
68+
if param_schema.get('properties'):
69+
def cast_leaves(d, schema):
70+
if type(d) is not dict:
71+
try:
72+
return make_type(d, schema['type'])
73+
except (ValueError, TypeError):
74+
return d
75+
for k, v in d.items():
76+
if k in schema['properties']:
77+
d[k] = cast_leaves(v, schema['properties'][k])
78+
return d
79+
80+
return cast_leaves(value, param_schema)
81+
return value
6682
else:
6783
try:
6884
return make_type(value, param_type)

connexion/operations/abstract.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def _query_args_helper(self, query_defns, query_arguments,
199199
logger.error("Function argument '{}' not defined in specification".format(key))
200200
else:
201201
logger.debug('%s is a %s', key, query_defn)
202-
res[key] = self._get_val_from_param(value, query_defn)
202+
res.update({key: self._get_val_from_param(value, query_defn)})
203203
return res
204204

205205
@abc.abstractmethod

connexion/operations/openapi.py

+11
Original file line numberDiff line numberDiff line change
@@ -322,5 +322,16 @@ def _get_val_from_param(self, value, query_defn):
322322

323323
if query_schema["type"] == "array":
324324
return [make_type(part, query_schema["items"]["type"]) for part in value]
325+
elif query_schema["type"] == "object" and 'properties' in query_schema:
326+
return_dict = {}
327+
for prop_key in query_schema['properties'].keys():
328+
prop_value = value.get(prop_key, None)
329+
if prop_value is not None: # False is a valid value for boolean values
330+
try:
331+
return_dict[prop_key] = make_type(value[prop_key],
332+
query_schema['properties'][prop_key]['type'])
333+
except (KeyError, TypeError):
334+
return value
335+
return return_dict
325336
else:
326337
return make_type(value, query_schema["type"])

connexion/utils.py

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import functools
22
import importlib
3+
import re
34

45
import six
56
import yaml
@@ -253,3 +254,12 @@ def ignore_aliases(self, *args):
253254
yaml.representer.SafeRepresenter.represent_scalar = my_represent_scalar
254255

255256
return yaml.dump(openapi, allow_unicode=True, Dumper=NoAnchorDumper)
257+
258+
259+
def create_empty_dict_from_list(_list, _dict, _end_value):
260+
"""create from ['foo', 'bar'] a dict like {'foo': {'bar': {}}} recursively. needed for converting query params"""
261+
current_key = _list.pop(0)
262+
if _list:
263+
return {current_key: create_empty_dict_from_list(_list, _dict, _end_value)}
264+
else:
265+
return {current_key: _end_value}

tests/api/test_responses.py

+36
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,42 @@ def test_empty(simple_app):
108108
assert not response.data
109109

110110

111+
def test_exploded_deep_object_param_endpoint_openapi_simple(simple_openapi_app):
112+
app_client = simple_openapi_app.app.test_client()
113+
114+
response = app_client.get('/v1.0/exploded-deep-object-param?id[foo]=bar&id[foofoo]=barbar') # type: flask.Response
115+
assert response.status_code == 200
116+
response_data = json.loads(response.data.decode('utf-8', 'replace'))
117+
assert response_data == {'foo': 'bar', 'foo4': 'blubb'}
118+
119+
120+
def test_exploded_deep_object_param_endpoint_openapi_multiple_data_types(simple_openapi_app):
121+
app_client = simple_openapi_app.app.test_client()
122+
123+
response = app_client.get('/v1.0/exploded-deep-object-param?id[foo]=bar&id[fooint]=2&id[fooboo]=false') # type: flask.Response
124+
assert response.status_code == 200
125+
response_data = json.loads(response.data.decode('utf-8', 'replace'))
126+
assert response_data == {'foo': 'bar', 'fooint': 2, 'fooboo': False, 'foo4': 'blubb'}
127+
128+
129+
def test_exploded_deep_object_param_endpoint_openapi_additional_properties(simple_openapi_app):
130+
app_client = simple_openapi_app.app.test_client()
131+
132+
response = app_client.get('/v1.0/exploded-deep-object-param-additional-properties?id[foo]=bar&id[fooint]=2') # type: flask.Response
133+
assert response.status_code == 200
134+
response_data = json.loads(response.data.decode('utf-8', 'replace'))
135+
assert response_data == {'foo': 'bar', 'fooint': '2'}
136+
137+
138+
def test_nested_exploded_deep_object_param_endpoint_openapi(simple_openapi_app):
139+
app_client = simple_openapi_app.app.test_client()
140+
141+
response = app_client.get('/v1.0/nested-exploded-deep-object-param?id[foo][foo2]=bar&id[foofoo]=barbar') # type: flask.Response
142+
assert response.status_code == 200
143+
response_data = json.loads(response.data.decode('utf-8', 'replace'))
144+
assert response_data == {'foo': {'foo2': 'bar', 'foo3': 'blubb'}, 'foofoo': 'barbar'}
145+
146+
111147
def test_redirect_endpoint(simple_app):
112148
app_client = simple_app.app.test_client()
113149
resp = app_client.get('/v1.0/test-redirect-endpoint')

tests/conftest.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
TEST_FOLDER = pathlib.Path(__file__).parent
1313
FIXTURES_FOLDER = TEST_FOLDER / 'fixtures'
1414
SPEC_FOLDER = TEST_FOLDER / "fakeapi"
15-
SPECS = ["swagger.yaml", "openapi.yaml"]
15+
OPENAPI2_SPEC = ["swagger.yaml"]
16+
OPENAPI3_SPEC = ["openapi.yaml"]
17+
SPECS = OPENAPI2_SPEC + OPENAPI3_SPEC
1618

1719

1820
class FakeResponse(object):
@@ -116,6 +118,11 @@ def simple_app(request):
116118
return build_app_from_fixture('simple', request.param, validate_responses=True)
117119

118120

121+
@pytest.fixture(scope="session", params=OPENAPI3_SPEC)
122+
def simple_openapi_app(request):
123+
return build_app_from_fixture('simple', request.param, validate_responses=True)
124+
125+
119126
@pytest.fixture(scope="session", params=SPECS)
120127
def snake_case_app(request):
121128
return build_app_from_fixture('snake_case', request.param,

tests/decorators/test_validation.py

+7
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ def test_get_nullable_parameter():
4343
assert result is None
4444

4545

46+
def test_get_explodable_object_parameter():
47+
param = {'schema': {'type': 'object', 'additionalProperties': True},
48+
'required': True, 'name': 'foo', 'style': 'deepObject', 'explode': True}
49+
result = ParameterValidator.validate_parameter('query', {'bar': 1}, param)
50+
assert result is None
51+
52+
4653
def test_invalid_type(monkeypatch):
4754
logger = MagicMock()
4855
monkeypatch.setattr('connexion.decorators.validation.logger', logger)

tests/fakeapi/hello.py

+12
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,18 @@ def test_required_param(simple):
318318
return simple
319319

320320

321+
def test_exploded_deep_object_param(id):
322+
return id
323+
324+
325+
def test_nested_exploded_deep_object_param(id):
326+
return id
327+
328+
329+
def test_exploded_deep_object_param_additional_properties(id):
330+
return id
331+
332+
321333
def test_redirect_endpoint():
322334
headers = {'Location': 'http://www.google.com/'}
323335
return '', 302, headers

tests/fixtures/simple/openapi.yaml

+98
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,104 @@ paths:
150150
responses:
151151
'204':
152152
description: empty
153+
/exploded-deep-object-param:
154+
get:
155+
summary: Returns dict response
156+
description: Returns dict response
157+
operationId: fakeapi.hello.test_exploded_deep_object_param
158+
parameters:
159+
- name: id
160+
required: true
161+
in: query
162+
style: deepObject
163+
explode: true
164+
schema:
165+
type: object
166+
properties:
167+
foo:
168+
type: string
169+
fooint:
170+
type: integer
171+
fooboo:
172+
type: boolean
173+
foo4:
174+
type: string
175+
default: blubb
176+
responses:
177+
'200':
178+
description: object response
179+
content:
180+
application/json:
181+
schema:
182+
type: object
183+
properties:
184+
foo:
185+
type: string
186+
foo4:
187+
type: string
188+
/exploded-deep-object-param-additional-properties:
189+
get:
190+
summary: Returns dict response with flexible properties
191+
description: Returns dict response with flexible properties
192+
operationId: fakeapi.hello.test_exploded_deep_object_param_additional_properties
193+
parameters:
194+
- name: id
195+
required: false
196+
in: query
197+
style: deepObject
198+
explode: true
199+
schema:
200+
type: object
201+
additionalProperties:
202+
type: string
203+
responses:
204+
'200':
205+
description: object response
206+
content:
207+
application/json:
208+
schema:
209+
type: object
210+
additionalProperties:
211+
type: string
212+
/nested-exploded-deep-object-param:
213+
get:
214+
summary: Returns nested dict response
215+
description: Returns nested dict response
216+
operationId: fakeapi.hello.test_nested_exploded_deep_object_param
217+
parameters:
218+
- name: id
219+
required: true
220+
in: query
221+
style: deepObject
222+
explode: true
223+
schema:
224+
type: object
225+
properties:
226+
foo:
227+
type: object
228+
properties:
229+
foo2:
230+
type: string
231+
foo3:
232+
type: string
233+
default: blubb
234+
foofoo:
235+
type: string
236+
responses:
237+
'200':
238+
description: object response
239+
content:
240+
application/json:
241+
schema:
242+
type: object
243+
properties:
244+
foo:
245+
type: object
246+
properties:
247+
foo2:
248+
type: string
249+
foo3:
250+
type: string
153251
/test-redirect-endpoint:
154252
get:
155253
summary: Tests handlers returning flask.Response objects

0 commit comments

Comments
 (0)