From 10dbf1316f9092dbd751b1e47fbcab45576ca8fc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Sep 2015 17:25:52 +0100 Subject: [PATCH 1/4] Added JSONField. Closes #3170. --- docs/api-guide/fields.md | 8 +++++ rest_framework/compat.py | 7 +++++ rest_framework/fields.py | 26 ++++++++++++++++ rest_framework/serializers.py | 3 ++ tests/test_fields.py | 56 +++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 2fbb84c032..3888e91004 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -459,6 +459,14 @@ You can also use the declarative style, as with `ListField`. For example: class DocumentField(DictField): child = CharField() +## JSONField + +A field class that validates that the incoming data structure consists of valid JSON primitives. In its alternate binary mode, it will represent and validate JSON encoded strings. + +**Signature**: `JSONField(binary)` + +- `binary` - If set to `True` then the field will output and validate a JSON encoded string, rather that a primitive data structure. Defaults to `False`. + --- # Miscellaneous fields diff --git a/rest_framework/compat.py b/rest_framework/compat.py index baed9d40a5..5ca5da20e1 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -64,6 +64,13 @@ def distinct(queryset, base): postgres_fields = None +# JSONField is only supported from 1.9 onwards +try: + from django.contrib.postgres.fields import JSONField +except ImportError: + JSONField = None + + # django-filter is optional try: import django_filters diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 1c051db6df..a1d99bd884 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -5,6 +5,7 @@ import datetime import decimal import inspect +import json import re import uuid from collections import OrderedDict @@ -1522,6 +1523,31 @@ def to_representation(self, value): ]) +class JSONField(Field): + default_error_messages = { + 'invalid': _('Value must be valid JSON.') + } + + def __init__(self, *args, **kwargs): + self.binary = kwargs.pop('binary', False) + super(JSONField, self).__init__(*args, **kwargs) + + def to_internal_value(self, data): + try: + if self.binary: + return json.loads(data) + else: + json.dumps(data) + except (TypeError, ValueError): + self.fail('invalid') + return data + + def to_representation(self, value): + if self.binary: + return json.dumps(value) + return value + + # Miscellaneous field types... class ReadOnlyField(Field): diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 0b0b1aa623..5aef1df69f 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -21,6 +21,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework.compat import DurationField as ModelDurationField +from rest_framework.compat import JSONField as ModelJSONField from rest_framework.compat import postgres_fields, unicode_to_repr from rest_framework.utils import model_meta from rest_framework.utils.field_mapping import ( @@ -790,6 +791,8 @@ class ModelSerializer(Serializer): } if ModelDurationField is not None: serializer_field_mapping[ModelDurationField] = DurationField + if ModelJSONField is not None: + serializer_field_mapping[ModelJSONField] = JSONField serializer_related_field = PrimaryKeyRelatedField serializer_url_field = HyperlinkedIdentityField serializer_choice_field = ChoiceField diff --git a/tests/test_fields.py b/tests/test_fields.py index 6048f49d00..270a0f6526 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1525,6 +1525,62 @@ class TestUnvalidatedDictField(FieldValues): field = serializers.DictField() +class TestJSONField(FieldValues): + """ + Values for `JSONField`. + """ + valid_inputs = [ + ({ + 'a': 1, + 'b': ['some', 'list', True, 1.23], + '3': None + }, { + 'a': 1, + 'b': ['some', 'list', True, 1.23], + '3': None + }), + ] + invalid_inputs = [ + ({'a': set()}, ['Value must be valid JSON.']), + ] + outputs = [ + ({ + 'a': 1, + 'b': ['some', 'list', True, 1.23], + '3': 3 + }, { + 'a': 1, + 'b': ['some', 'list', True, 1.23], + '3': 3 + }), + ] + field = serializers.JSONField() + + +class TestBinaryJSONField(FieldValues): + """ + Values for `JSONField` with binary=True. + """ + valid_inputs = [ + ('{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}', { + 'a': 1, + 'b': ['some', 'list', True, 1.23], + '3': None + }), + ] + invalid_inputs = [ + ('{"a": "unterminated string}', ['Value must be valid JSON.']), + ] + outputs = [ + ({ + 'a': 1, + 'b': ['some', 'list', True, 1.23], + '3': None + }, '{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}'), + ] + field = serializers.JSONField(binary=True) + + # Tests for FieldField. # --------------------- From ec8098b7e2b69e51c1a3196c20d11b253f914926 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Sep 2015 17:32:36 +0100 Subject: [PATCH 2/4] Work around 2.x/3.x json.dumps() return type fuzziness --- rest_framework/fields.py | 6 +++++- tests/test_fields.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index a1d99bd884..38db39287a 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1544,7 +1544,11 @@ def to_internal_value(self, data): def to_representation(self, value): if self.binary: - return json.dumps(value) + value = json.dumps(value) + # On python 2.x the return type for json.dumps() is underspecified. + # On python 3.x json.dumps() returns unicode strings. + if isinstance(value, six.text_type): + value = bytes(value.encode('utf-8')) return value diff --git a/tests/test_fields.py b/tests/test_fields.py index 270a0f6526..bcd65a1a64 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1562,7 +1562,7 @@ class TestBinaryJSONField(FieldValues): Values for `JSONField` with binary=True. """ valid_inputs = [ - ('{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}', { + (b'{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}', { 'a': 1, 'b': ['some', 'list', True, 1.23], '3': None @@ -1576,7 +1576,7 @@ class TestBinaryJSONField(FieldValues): 'a': 1, 'b': ['some', 'list', True, 1.23], '3': None - }, '{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}'), + }, b'{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}'), ] field = serializers.JSONField(binary=True) From dad207de6687fc5d9fd32b47d64932f0982617a7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Sep 2015 17:41:09 +0100 Subject: [PATCH 3/4] Don't attempt to test dicts (unordered) --- tests/test_fields.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index bcd65a1a64..1043376275 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1572,11 +1572,7 @@ class TestBinaryJSONField(FieldValues): ('{"a": "unterminated string}', ['Value must be valid JSON.']), ] outputs = [ - ({ - 'a': 1, - 'b': ['some', 'list', True, 1.23], - '3': None - }, b'{"a": 1, "3": null, "b": ["some", "list", true, 1.23]}'), + (['some', 'list', True, 1.23], b'["some", "list", true, 1.23]'), ] field = serializers.JSONField(binary=True) From 265ec8ac62ef4b2b8ee74525e7c263bf559a2355 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Sep 2015 17:47:51 +0100 Subject: [PATCH 4/4] Handle binary or unicode with JSONField --- docs/api-guide/fields.md | 2 +- rest_framework/fields.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 3888e91004..f8be0c1b93 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -461,7 +461,7 @@ You can also use the declarative style, as with `ListField`. For example: ## JSONField -A field class that validates that the incoming data structure consists of valid JSON primitives. In its alternate binary mode, it will represent and validate JSON encoded strings. +A field class that validates that the incoming data structure consists of valid JSON primitives. In its alternate binary mode, it will represent and validate JSON-encoded binary strings. **Signature**: `JSONField(binary)` diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 38db39287a..0d4a511526 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1535,6 +1535,8 @@ def __init__(self, *args, **kwargs): def to_internal_value(self, data): try: if self.binary: + if isinstance(data, six.binary_type): + data = data.decode('utf-8') return json.loads(data) else: json.dumps(data)