diff --git a/CHANGELOG.md b/CHANGELOG.md index 04069795..3dd15afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ v2.5.0 - [unreleased] * Add new pagination classes based on JSON:API query parameter *recommendations*: - * JsonApiPageNumberPagination and JsonApiLimitOffsetPagination. See [usage docs](docs/usage.md#pagination). - * Deprecates PageNumberPagination and LimitOffsetPagination. -* Add ReadOnlyModelViewSet extension with prefetch mixins. + * `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination`. See [usage docs](docs/usage.md#pagination). + * Deprecates `PageNumberPagination` and `LimitOffsetPagination`. +* Add `ReadOnlyModelViewSet` extension with prefetch mixins. * Add support for Django REST Framework 3.8.x +* Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparision preserving + values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). + * `JSON_API_FORMAT_KEYS` still works as before (formating all json value keys also nested) but is marked as deprecated. v2.4.0 - Released January 25, 2018 diff --git a/docs/usage.md b/docs/usage.md index 990638f9..c8727f1b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -149,13 +149,13 @@ multiple endpoints. Setting the `resource_name` on views may result in a differe ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert json -requests and responses from the python/rest_framework's preferred underscore to +This package includes the ability (off by default) to automatically convert [json +api field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: ``` python -JSON_API_FORMAT_KEYS = 'dasherize' +JSON_API_FORMAT_FIELD_NAMES = 'dasherize' ``` Possible values: diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index 470bd79c..a00def74 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -37,7 +37,7 @@ def posts(self, request): encoding.force_text('identities'): IdentitySerializer(identities, many=True).data, encoding.force_text('posts'): PostSerializer(posts, many=True).data, } - return Response(utils.format_keys(data, format_type='camelize')) + return Response(utils.format_field_names(data, format_type='camelize')) @detail_route() def manual_resource_name(self, request, *args, **kwargs): diff --git a/example/settings/dev.py b/example/settings/dev.py index 61dfa443..01e2fef1 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -65,7 +65,7 @@ INTERNAL_IPS = ('127.0.0.1', ) -JSON_API_FORMAT_KEYS = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' REST_FRAMEWORK = { 'PAGE_SIZE': 5, diff --git a/example/settings/test.py b/example/settings/test.py index 1f0e959d..bbf6e400 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -9,7 +9,7 @@ ROOT_URLCONF = 'example.urls_test' -JSON_API_FORMAT_KEYS = 'camelize' +JSON_API_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' JSON_API_PLURALIZE_TYPES = True REST_FRAMEWORK.update({ diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index bfde51ea..15d9bd1e 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -4,7 +4,7 @@ from example.tests import TestBase -@override_settings(JSON_API_FORMAT_KEYS='dasherize') +@override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') class GenericViewSet(TestBase): """ Test expected responses coming from a Generic ViewSet diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 21c6d41a..ee3e4ba0 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -7,7 +7,7 @@ from example.tests import TestBase -@override_settings(JSON_API_FORMAT_KEYS='dasherize') +@override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') class ModelViewSetTests(TestBase): """ Test usage with ModelViewSets, also tests pluralization, camelization, diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py index 3c7a102c..aec80a12 100644 --- a/example/tests/test_parsers.py +++ b/example/tests/test_parsers.py @@ -1,7 +1,7 @@ import json from io import BytesIO -from django.test import TestCase +from django.test import TestCase, override_settings from rest_framework.exceptions import ParseError from rest_framework_json_api.parsers import JSONParser @@ -22,7 +22,10 @@ def __init__(self): data = { 'data': { 'id': 123, - 'type': 'Blog' + 'type': 'Blog', + 'attributes': { + 'json-value': {'JsonKey': 'JsonValue'} + }, }, 'meta': { 'random_key': 'random_value' @@ -31,13 +34,25 @@ def __init__(self): self.string = json.dumps(data) - def test_parse_include_metadata(self): + @override_settings(JSON_API_FORMAT_KEYS='camelize') + def test_parse_include_metadata_format_keys(self): parser = JSONParser() stream = BytesIO(self.string.encode('utf-8')) data = parser.parse(stream, None, self.parser_context) self.assertEqual(data['_meta'], {'random_key': 'random_value'}) + self.assertEqual(data['json_value'], {'json_key': 'JsonValue'}) + + @override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') + def test_parse_include_metadata_format_field_names(self): + parser = JSONParser() + + stream = BytesIO(self.string.encode('utf-8')) + data = parser.parse(stream, None, self.parser_context) + + self.assertEqual(data['_meta'], {'random_key': 'random_value'}) + self.assertEqual(data['json_value'], {'JsonKey': 'JsonValue'}) def test_parse_invalid_data(self): parser = JSONParser() diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index de40afac..42a45a94 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -1,3 +1,5 @@ +import json + from rest_framework_json_api import serializers, views from rest_framework_json_api.renderers import JSONRenderer @@ -19,9 +21,14 @@ class DummyTestSerializer(serializers.ModelSerializer): related_models = RelatedModelSerializer( source='comments', many=True, read_only=True) + json_field = serializers.SerializerMethodField() + + def get_json_field(self, entry): + return {'JsonKey': 'JsonValue'} + class Meta: model = Entry - fields = ('related_models',) + fields = ('related_models', 'json_field') class JSONAPIMeta: included_resources = ('related_models',) @@ -61,3 +68,22 @@ def test_simple_reverse_relation_included_read_only_viewset(): ReadOnlyDummyTestViewSet) assert rendered + + +def test_render_format_field_names(settings): + """Test that json field is kept untouched.""" + settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + rendered = render_dummy_test_serialized_view(DummyTestViewSet) + + result = json.loads(rendered.decode()) + assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + + +def test_render_format_keys(settings): + """Test that json field value keys are formated.""" + delattr(settings, 'JSON_API_FORMAT_FILED_NAMES') + settings.JSON_API_FORMAT_KEYS = 'dasherize' + rendered = render_dummy_test_serialized_view(DummyTestViewSet) + + result = json.loads(rendered.decode()) + assert result['data']['attributes']['json-field'] == {'json-key': 'JsonValue'} diff --git a/example/tests/unit/test_settings.py b/example/tests/unit/test_settings.py index 516e76ec..e6b82a24 100644 --- a/example/tests/unit/test_settings.py +++ b/example/tests/unit/test_settings.py @@ -13,5 +13,5 @@ def test_settings_default(): def test_settings_override(settings): - settings.JSON_API_FORMAT_KEYS = 'dasherize' - assert json_api_settings.FORMAT_KEYS == 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + assert json_api_settings.FORMAT_FIELD_NAMES == 'dasherize' diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py index 46315772..a1414050 100644 --- a/example/tests/unit/test_utils.py +++ b/example/tests/unit/test_utils.py @@ -69,7 +69,8 @@ def test_format_keys(): } output = {'firstName': 'a', 'lastName': 'b'} - assert utils.format_keys(underscored, 'camelize') == output + result = pytest.deprecated_call(utils.format_keys, underscored, 'camelize') + assert result == output output = {'FirstName': 'a', 'LastName': 'b'} assert utils.format_keys(underscored, 'capitalize') == output @@ -84,6 +85,19 @@ def test_format_keys(): assert utils.format_keys([underscored], 'dasherize') == output +@pytest.mark.parametrize("format_type,output", [ + ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), + ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), + ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), + ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), +]) +def test_format_field_names(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} + assert utils.format_field_names(value, format_type) == output + + def test_format_value(): assert utils.format_value('first_name', 'camelize') == 'firstName' assert utils.format_value('first_name', 'capitalize') == 'FirstName' diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 98873d2e..8459f3ba 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -32,26 +32,26 @@ class JSONParser(parsers.JSONParser): @staticmethod def parse_attributes(data): attributes = data.get('attributes') - uses_format_translation = json_api_settings.FORMAT_KEYS + uses_format_translation = json_api_settings.format_type if not attributes: return dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - return utils.format_keys(attributes, 'underscore') + return utils._format_object(attributes, 'underscore') else: return attributes @staticmethod def parse_relationships(data): - uses_format_translation = json_api_settings.FORMAT_KEYS + uses_format_translation = json_api_settings.format_type relationships = data.get('relationships') if not relationships: relationships = dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - relationships = utils.format_keys(relationships, 'underscore') + relationships = utils._format_object(relationships, 'underscore') # Parse the relationships parsed_relationships = dict() diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 6059d2a2..ba6424ee 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -68,7 +68,7 @@ def extract_attributes(cls, fields, resource): field_name: resource.get(field_name) }) - return utils.format_keys(data) + return utils._format_object(data) @classmethod def extract_relationships(cls, fields, resource, resource_instance): @@ -281,7 +281,7 @@ def extract_relationships(cls, fields, resource, resource_instance): }) continue - return utils.format_keys(data) + return utils._format_object(data) @classmethod def extract_relation_instance(cls, field_name, field, resource_instance, serializer): @@ -405,7 +405,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource getattr(serializer, '_poly_force_type_resolution', False) ) included_cache[new_item['type']][new_item['id']] = \ - utils.format_keys(new_item) + utils._format_object(new_item) cls.extract_included( serializer_fields, serializer_resource, @@ -427,7 +427,9 @@ def extract_included(cls, fields, resource, resource_instance, included_resource relation_type, getattr(field, '_poly_force_type_resolution', False) ) - included_cache[new_item['type']][new_item['id']] = utils.format_keys(new_item) + included_cache[new_item['type']][new_item['id']] = utils._format_object( + new_item + ) cls.extract_included( serializer_fields, serializer_data, @@ -577,7 +579,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) meta = self.extract_meta(serializer, resource) if meta: - json_resource_obj.update({'meta': utils.format_keys(meta)}) + json_resource_obj.update({'meta': utils._format_object(meta)}) json_api_data.append(json_resource_obj) self.extract_included( @@ -594,7 +596,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): meta = self.extract_meta(serializer, serializer_data) if meta: - json_api_data.update({'meta': utils.format_keys(meta)}) + json_api_data.update({'meta': utils._format_object(meta)}) self.extract_included( fields, serializer_data, resource_instance, included_resources, included_cache @@ -620,7 +622,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): render_data['included'].append(included_cache[included_type][included_id]) if json_api_meta: - render_data['meta'] = utils.format_keys(json_api_meta) + render_data['meta'] = utils._format_object(json_api_meta) return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 40c5a96b..6c7eeffe 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -10,12 +10,15 @@ JSON_API_SETTINGS_PREFIX = 'JSON_API_' DEFAULTS = { - 'FORMAT_KEYS': False, - 'FORMAT_RELATION_KEYS': None, + 'FORMAT_FIELD_NAMES': False, 'FORMAT_TYPES': False, - 'PLURALIZE_RELATION_TYPE': None, 'PLURALIZE_TYPES': False, 'UNIFORM_EXCEPTIONS': False, + + # deprecated settings to be removed in the future + 'FORMAT_KEYS': None, + 'FORMAT_RELATION_KEYS': None, + 'PLURALIZE_RELATION_TYPE': None, } @@ -39,6 +42,13 @@ def __getattr__(self, attr): setattr(self, attr, value) return value + @property + def format_type(self): + if self.FORMAT_KEYS is not None: + return self.FORMAT_KEYS + + return self.FORMAT_FIELD_NAMES + json_api_settings = JSONAPISettings() diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index a7220084..39000216 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -98,13 +98,50 @@ def get_serializer_fields(serializer): return fields +def format_field_names(obj, format_type=None): + """ + Takes a dict and returns it with formatted keys as set in `format_type` + or `JSON_API_FORMAT_FIELD_NAMES` + + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' + """ + if format_type is None: + format_type = json_api_settings.FORMAT_FIELD_NAMES + + if isinstance(obj, dict): + formatted = OrderedDict() + for key, value in obj.items(): + key = format_value(key, format_type) + formatted[key] = value + return formatted + + return obj + + +def _format_object(obj, format_type=None): + """Depending on settings calls either `format_keys` or `format_field_names`""" + + if json_api_settings.FORMAT_KEYS is not None: + return format_keys(obj, format_type) + + return format_field_names(obj, format_type) + + def format_keys(obj, format_type=None): """ Takes either a dict or list and returns it with camelized keys only if JSON_API_FORMAT_KEYS is set. - :format_type: Either 'dasherize', 'camelize' or 'underscore' + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' """ + warnings.warn( + "`format_keys` function and `JSON_API_FORMAT_KEYS` setting are deprecated and will be " + "removed in the future. " + "Use `format_field_names` and `JSON_API_FIELD_NAMES` instead. Be aware that " + "`format_field_names` only formats keys and preserves value.", + DeprecationWarning + ) + if format_type is None: format_type = json_api_settings.FORMAT_KEYS @@ -138,7 +175,7 @@ def format_keys(obj, format_type=None): def format_value(value, format_type=None): if format_type is None: - format_type = json_api_settings.FORMAT_KEYS + format_type = json_api_settings.format_type if format_type == 'dasherize': # inflection can't dasherize camelCase value = inflection.underscore(value) @@ -155,7 +192,9 @@ def format_value(value, format_type=None): def format_relation_name(value, format_type=None): warnings.warn( "The 'format_relation_name' function has been renamed 'format_resource_type' and the " - "settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES'" + "settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES' instead of " + "'JSON_API_FORMAT_RELATION_KEYS' and 'JSON_API_PLURALIZE_RELATION_TYPE'", + DeprecationWarning ) if format_type is None: format_type = json_api_settings.FORMAT_RELATION_KEYS