Projet

Général

Profil

0001-api-apply-unflatten-to-input-JSON-66742.patch

Benjamin Dauvergne, 29 juin 2022 13:26

Télécharger (8,52 ko)

Voir les différences:

Subject: [PATCH] api: apply unflatten to input JSON (#66742)

It should help dumb clients to make API calls.
 src/authentic2/settings.py             |  5 ++
 src/authentic2/utils/rest_framework.py | 97 ++++++++++++++++++++++++++
 tests/api/test_roles.py                |  9 +++
 tests/test_utils_rest_framework.py     | 93 ++++++++++++++++++++++++
 4 files changed, 204 insertions(+)
 create mode 100644 src/authentic2/utils/rest_framework.py
 create mode 100644 tests/test_utils_rest_framework.py
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
-