0001-api-apply-unflatten-to-input-JSON-66742.patch
src/authentic2/settings.py | ||
---|---|---|
309 | 309 |
# Django REST Framework |
310 | 310 |
REST_FRAMEWORK = { |
311 | 311 |
'NON_FIELD_ERRORS_KEY': '__all__', |
312 |
'DEFAULT_PARSER_CLASSES': [ |
|
313 |
'authentic2.utils.rest_framework.UnflattenJSONParser', |
|
314 |
'rest_framework.parsers.FormParser', |
|
315 |
'rest_framework.parsers.MultiPartParser', |
|
316 |
], |
|
312 | 317 |
'DEFAULT_AUTHENTICATION_CLASSES': ( |
313 | 318 |
'authentic2.authentication.Authentic2Authentication', |
314 | 319 |
'rest_framework.authentication.SessionAuthentication', |
src/authentic2/utils/rest_framework.py | ||
---|---|---|
1 |
# authentic2 - versatile identity manager |
|
2 |
# Copyright (C) 2022 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from rest_framework.parsers import JSONParser |
|
18 | ||
19 |
_FLATTEN_SEPARATOR = '/' |
|
20 | ||
21 | ||
22 |
def _is_number(string): |
|
23 |
if hasattr(string, 'isdecimal'): |
|
24 |
return string.isdecimal() and [ord(c) < 256 for c in string] |
|
25 |
else: # str PY2 |
|
26 |
return string.isdigit() |
|
27 | ||
28 | ||
29 |
def _unflatten(d, separator=_FLATTEN_SEPARATOR): |
|
30 |
"""Transform: |
|
31 | ||
32 |
{"a/b/0/x": "1234"} |
|
33 | ||
34 |
into: |
|
35 | ||
36 |
{"a": {"b": [{"x": "1234"}]}} |
|
37 |
""" |
|
38 |
if not isinstance(d, dict) or not d: # unflattening an empty dict has no sense |
|
39 |
return d |
|
40 | ||
41 |
# ok d is a dict |
|
42 | ||
43 |
def map_digits(parts): |
|
44 |
return [int(x) if _is_number(x) else x for x in parts] |
|
45 | ||
46 |
keys = [(map_digits(key.split(separator)), key) for key in d] |
|
47 |
keys.sort() |
|
48 | ||
49 |
def set_path(path, orig_key, d, value, i=0): |
|
50 |
assert path |
|
51 | ||
52 |
key, tail = path[i], path[i + 1 :] |
|
53 | ||
54 |
if not tail: # end of path, set thevalue |
|
55 |
if isinstance(key, int): |
|
56 |
assert isinstance(d, list) |
|
57 |
if len(d) != key: |
|
58 |
raise ValueError('incomplete array before %s' % orig_key) |
|
59 |
d.append(value) |
|
60 |
else: |
|
61 |
assert isinstance(d, dict) |
|
62 |
d[key] = value |
|
63 |
else: |
|
64 |
new = [] if isinstance(tail[0], int) else {} |
|
65 | ||
66 |
if isinstance(key, int): |
|
67 |
assert isinstance(d, list) |
|
68 |
if len(d) < key: |
|
69 |
raise ValueError( |
|
70 |
'incomplete array before %s in %s' |
|
71 |
% (separator.join(map(str, path[: i + 1])), orig_key) |
|
72 |
) |
|
73 |
if len(d) == key: |
|
74 |
d.append(new) |
|
75 |
else: |
|
76 |
new = d[key] |
|
77 |
else: |
|
78 |
new = d.setdefault(key, new) |
|
79 |
set_path(path, orig_key, new, value, i + 1) |
|
80 | ||
81 |
# Is the first level an array or a dict ? |
|
82 |
if isinstance(keys[0][0][0], int): |
|
83 |
new = [] |
|
84 |
else: |
|
85 |
new = {} |
|
86 |
for path, key in keys: |
|
87 |
value = d[key] |
|
88 |
set_path(path, key, new, value) |
|
89 |
return new |
|
90 | ||
91 | ||
92 |
class UnflattenJSONParser(JSONParser): |
|
93 |
def parse(self, *args, **kwargs): |
|
94 |
result = super().parse(*args, **kwargs) |
|
95 |
if isinstance(result, dict) and any('/' in key for key in result): |
|
96 |
result = _unflatten(result) |
|
97 |
return result |
tests/api/test_roles.py | ||
---|---|---|
222 | 222 |
assert resp.json == {'err': 0, 'data': []} |
223 | 223 |
assert not set(roles.grandchild.children(include_self=False, direct=True)) |
224 | 224 | |
225 |
def test_delete_unflatten(self, app, roles): |
|
226 |
assert set(roles.parent.children(include_self=False, direct=True)) == {roles.child} |
|
227 |
resp = app.delete_json( |
|
228 |
'/api/roles/%s/relationships/parents/' % roles.grandchild.uuid, |
|
229 |
params={'parent/uuid': roles.child.uuid}, |
|
230 |
) |
|
231 |
assert resp.json == {'err': 0, 'data': []} |
|
232 |
assert not set(roles.grandchild.children(include_self=False, direct=True)) |
|
233 | ||
225 | 234 |
class TestPermission: |
226 | 235 |
@pytest.fixture |
227 | 236 |
def user(self, simple_user): |
tests/test_utils_rest_framework.py | ||
---|---|---|
1 |
# authentic2 - versatile identity manager |
|
2 |
# Copyright (C) 2010-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 |
# authentic2 |
|
17 | ||
18 |
import io |
|
19 |
import json |
|
20 | ||
21 |
import pytest |
|
22 | ||
23 |
from authentic2.utils.rest_framework import _FLATTEN_SEPARATOR as SEP |
|
24 |
from authentic2.utils.rest_framework import UnflattenJSONParser |
|
25 |
from authentic2.utils.rest_framework import _unflatten as unflatten |
|
26 | ||
27 | ||
28 |
def test_unflatten_base(): |
|
29 |
assert unflatten('') == '' |
|
30 |
assert unflatten('a') == 'a' |
|
31 |
assert unflatten([]) == [] |
|
32 |
assert unflatten([1]) == [1] |
|
33 |
assert unflatten({}) == {} |
|
34 |
assert unflatten(0) == 0 |
|
35 |
assert unflatten(1) == 1 |
|
36 |
assert unflatten(False) is False |
|
37 |
assert unflatten(True) is True |
|
38 | ||
39 | ||
40 |
def test_unflatten_dict(): |
|
41 |
assert unflatten( |
|
42 |
{ |
|
43 |
'a' + SEP + 'b' + SEP + '0': 1, |
|
44 |
'a' + SEP + 'c' + SEP + '1': 'a', |
|
45 |
'a' + SEP + 'b' + SEP + '1': True, |
|
46 |
'a' + SEP + 'c' + SEP + '0': [1], |
|
47 |
} |
|
48 |
) == { |
|
49 |
'a': { |
|
50 |
'b': [1, True], |
|
51 |
'c': [[1], 'a'], |
|
52 |
} |
|
53 |
} |
|
54 | ||
55 | ||
56 |
def test_unflatten_array(): |
|
57 |
assert unflatten( |
|
58 |
{ |
|
59 |
'0' + SEP + 'b' + SEP + '0': 1, |
|
60 |
'1' + SEP + 'c' + SEP + '1': 'a', |
|
61 |
'0' + SEP + 'b' + SEP + '1': True, |
|
62 |
'1' + SEP + 'c' + SEP + '0': [1], |
|
63 |
} |
|
64 |
) == [{'b': [1, True]}, {'c': [[1], 'a']}] |
|
65 | ||
66 | ||
67 |
def test_unflatten_missing_final_index(): |
|
68 |
with pytest.raises(ValueError) as exc_info: |
|
69 |
unflatten({'1': 1}) |
|
70 |
assert 'incomplete' in exc_info.value.args[0] |
|
71 | ||
72 | ||
73 |
def test_unflatten_missing_intermediate_index(): |
|
74 |
with pytest.raises(ValueError) as exc_info: |
|
75 |
unflatten({'a' + SEP + '1' + SEP + 'b': 1}) |
|
76 |
assert 'incomplete' in exc_info.value.args[0] |
|
77 | ||
78 | ||
79 |
class TestUnflattenJsonParser: |
|
80 |
@pytest.fixture |
|
81 |
def parser(self): |
|
82 |
return UnflattenJSONParser() |
|
83 | ||
84 |
def test_parse(self, parser): |
|
85 |
in_json = { |
|
86 |
'a/b/c': {'d/e': 1}, |
|
87 |
'b/0': 1, |
|
88 |
'b/1': 2, |
|
89 |
} |
|
90 |
out_json = {'a': {'b': {'c': {'d/e': 1}}}, 'b': [1, 2]} |
|
91 | ||
92 |
stream = io.BytesIO(json.dumps(in_json).encode()) |
|
93 |
assert parser.parse(stream) == out_json |
|
0 |
- |