diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7bd08370..3df808cd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ Current ------- - Ensure `basePath` is always a path +- Add `dot_escape` kwarg to `fields.Raw` to prevent nested property access 0.12.1 (2018-09-28) ------------------- diff --git a/doc/marshalling.rst b/doc/marshalling.rst index 098c9369..0ecc66dc 100644 --- a/doc/marshalling.rst +++ b/doc/marshalling.rst @@ -107,6 +107,58 @@ you can specify a default value to return instead of :obj:`None`. } +Nested Field Names +------------------ + +By default, '.' is used as a separator to indicate nested properties when values +are fetched from objects: + +.. code-block:: python + + data = { + 'address': { + 'country': 'UK', + 'postcode': 'CO1' + } + } + + model = { + 'address.country': fields.String, + 'address.postcode': fields.String, + } + + marshal(data, model) + {'address.country': 'UK', 'address.postcode': 'CO1'} + +If the object to be marshalled has '.' characters within a single field name, +nested property access can be prevented by passing `dot_escape=True` and escaping +the '.' with a backslash: + +.. code-block:: python + + data = { + 'address.country': 'UK', + 'address.postcode': 'CO1', + 'user.name': { + 'first': 'John', + 'last': 'Smith', + } + } + + model = { + 'address\.country': fields.String(dot_escape=True), + 'address\.postcode': fields.String(dot_escape=True), + 'user\.name.first': fields.String(dot_escape=True), + 'user\.name.last': fields.String(dot_escape=True), + } + + marshal(data, model) + {'address.country': 'UK', + 'address.postcode': 'CO1', + 'user.name.first': 'John', + 'user.name.last': 'Smith'} + + Custom Fields & Multiple Values ------------------------------- diff --git a/flask_restplus/fields.py b/flask_restplus/fields.py index 7e75700a..622a5fc1 100644 --- a/flask_restplus/fields.py +++ b/flask_restplus/fields.py @@ -42,14 +42,39 @@ def is_indexable_but_not_string(obj): return not hasattr(obj, "strip") and hasattr(obj, "__iter__") -def get_value(key, obj, default=None): - '''Helper for pulling a keyed value off various types of objects''' +def get_value(key, obj, default=None, dot_escape=False): + '''Helper for pulling a keyed value off various types of objects + + :param bool dot_escape: Allow escaping of '.' character in field names to + indicate non-nested property access + + >>> data = {'a': 'foo', b: {'c': 'bar', 'd.e': 'baz'}}} + >>> get_value('a', data) + 'foo' + + >>> get_value('b.c', data) + 'bar' + + >>> get_value('x', data, default='foobar') + 'foobar' + + >>> get_value('b.d\.e', data, dot_escape=True) + 'baz' + ''' if isinstance(key, int): return _get_value_for_key(key, obj, default) elif callable(key): return key(obj) else: - return _get_value_for_keys(key.split('.'), obj, default) + keys = ( + [ + k.replace("\.", ".") + for k in re.split(r"(?>> marshal(data, mfields, skip_none=True, ordered=True) OrderedDict([('a', 100)]) + >>> data = { 'a': 100, 'b.c': 'foo', 'd': None } + >>> mfields = { + 'a': fields.Raw, + 'b\.c': fields.Raw(dot_escape=True), + 'd': fields.Raw + } + + >>> marshal(data, mfields) + {'a': 100, 'b.c': 'foo', 'd': None} """ # ugly local import to avoid dependency loop from .fields import Wildcard @@ -171,6 +180,8 @@ def __format_field(key, val): if isinstance(field, Wildcard): has_wildcards['present'] = True value = field.output(key, data, ordered=ordered) + if field.dot_escape: + key = key.replace("\.", ".") return (key, value) items = ( diff --git a/tests/test_fields.py b/tests/test_fields.py index d0117353..c1646b79 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -179,6 +179,18 @@ def test_nested_object(self, mocker): field = fields.Raw() assert field.output('bar.value', foo) == 42 + @pytest.mark.parametrize( + "foo,key,val", + [ + ({'not.nested.value': 42}, 'not\.nested\.value', 42), + ({'semi': {'nested.value': 42}}, 'semi.nested\.value', 42), + ({'completely': {'nested': {'value': 42}}}, 'completely.nested.value', 42), + ] + ) + def test_dot_escape(self, foo, key, val): + field = fields.Raw(dot_escape=True) + assert field.output(key, foo) == val + class StringFieldTest(StringTestMixin, BaseFieldTestMixin, FieldTestCase): field_class = fields.String diff --git a/tests/test_marshalling.py b/tests/test_marshalling.py index 6e971267..53a44225 100644 --- a/tests/test_marshalling.py +++ b/tests/test_marshalling.py @@ -166,6 +166,46 @@ def test_marshal_nested(self): assert output == expected + def test_marshal_dot_escape(self): + model = { + 'foo\.bar': fields.Raw(dot_escape=True), + 'baz': fields.Raw, + } + + marshal_fields = { + 'foo.bar': 'bar', + 'baz': 'foobar', + } + expected = { + 'foo.bar': 'bar', + 'baz': 'foobar', + } + + output = marshal(marshal_fields, model) + + assert output == expected + + def test_marshal_dot_escape_partial(self): + model = { + 'foo\.bar.baz': fields.Raw(dot_escape=True), + 'bat': fields.Raw, + } + + marshal_fields = { + 'foo.bar': { + 'baz': 'fee' + }, + 'bat': 'fye', + } + expected = { + 'foo.bar.baz': 'fee', + 'bat': 'fye', + } + + output = marshal(marshal_fields, model) + + assert output == expected + def test_marshal_nested_ordered(self): model = OrderedDict([ ('foo', fields.Raw),