From 634ddf96f2dd8fa676c4736628c8e7b08089fa68 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Tue, 28 Jun 2022 21:04:00 +0200 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 +++ 3 files changed, 111 insertions(+) create mode 100644 src/authentic2/utils/rest_framework.py diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index 483ca716..f3136dfc 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -309,6 +309,11 @@ MIGRATION_MODULES = { # Django REST Framework REST_FRAMEWORK = { 'NON_FIELD_ERRORS_KEY': '__all__', + 'DEFAULT_PARSER_CLASSES': [ + 'authentic2.utils.rest_framework.UnflattenJSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser', + ], 'DEFAULT_AUTHENTICATION_CLASSES': ( 'authentic2.authentication.Authentic2Authentication', 'rest_framework.authentication.SessionAuthentication', diff --git a/src/authentic2/utils/rest_framework.py b/src/authentic2/utils/rest_framework.py new file mode 100644 index 00000000..8f5a031a --- /dev/null +++ b/src/authentic2/utils/rest_framework.py @@ -0,0 +1,97 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from rest_framework.parsers import JSONParser + +FLATTEN_SEPARATOR = '/' + + +def _is_number(string): + if hasattr(string, 'isdecimal'): + return string.isdecimal() and [ord(c) < 256 for c in string] + else: # str PY2 + return string.isdigit() + + +def _unflatten(d, separator=FLATTEN_SEPARATOR): + """Transform: + + {"a/b/0/x": "1234"} + + into: + + {"a": {"b": [{"x": "1234"}]}} + """ + if not isinstance(d, dict) or not d: # unflattening an empty dict has no sense + return d + + # ok d is a dict + + def map_digits(parts): + return [int(x) if _is_number(x) else x for x in parts] + + keys = [(map_digits(key.split(separator)), key) for key in d] + keys.sort() + + def set_path(path, orig_key, d, value, i=0): + assert path + + key, tail = path[i], path[i + 1 :] + + if not tail: # end of path, set thevalue + if isinstance(key, int): + assert isinstance(d, list) + if len(d) != key: + raise ValueError('incomplete array before %s' % orig_key) + d.append(value) + else: + assert isinstance(d, dict) + d[key] = value + else: + new = [] if isinstance(tail[0], int) else {} + + if isinstance(key, int): + assert isinstance(d, list) + if len(d) < key: + raise ValueError( + 'incomplete array before %s in %s' + % (separator.join(map(str, path[: i + 1])), orig_key) + ) + if len(d) == key: + d.append(new) + else: + new = d[key] + else: + new = d.setdefault(key, new) + set_path(path, orig_key, new, value, i + 1) + + # Is the first level an array or a dict ? + if isinstance(keys[0][0][0], int): + new = [] + else: + new = {} + for path, key in keys: + value = d[key] + set_path(path, key, new, value) + return new + + +class UnflattenJSONParser(JSONParser): + def parse(self, *args, **kwargs): + result = super().parse(*args, **kwargs) + if isinstance(result, dict) and any('/' in key for key in result): + result = _unflatten(result) + return result diff --git a/tests/api/test_roles.py b/tests/api/test_roles.py index a517a5a4..710a4f75 100644 --- a/tests/api/test_roles.py +++ b/tests/api/test_roles.py @@ -222,6 +222,15 @@ class TestViews: assert resp.json == {'err': 0, 'data': []} assert not set(roles.grandchild.children(include_self=False, direct=True)) + def test_delete_unflatten(self, app, roles): + assert set(roles.parent.children(include_self=False, direct=True)) == {roles.child} + resp = app.delete_json( + '/api/roles/%s/relationships/parents/' % roles.grandchild.uuid, + params={'parent/uuid': roles.child.uuid}, + ) + assert resp.json == {'err': 0, 'data': []} + assert not set(roles.grandchild.children(include_self=False, direct=True)) + class TestPermission: @pytest.fixture def user(self, simple_user): -- 2.35.1