diff --git a/README.rst b/README.rst index 833be221..c143cce0 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Rest Framework. Settings ^^^^^^^^ -One can either add ``rest_framework_ember.parsers.EmberJSONParser`` and +One can either add ``rest_framework_ember.parsers.JSONParser`` and ``rest_framework_ember.renderers.JSONRenderer`` to each ``ViewSet`` class, or override ``settings.REST_FRAMEWORK``:: @@ -104,9 +104,9 @@ override ``settings.REST_FRAMEWORK``:: 'PAGINATE_BY_PARAM': 'page_size', 'MAX_PAGINATE_BY': 100, 'DEFAULT_PAGINATION_SERIALIZER_CLASS': - 'rest_framework_ember.pagination.EmberPaginationSerializer', + 'rest_framework_ember.pagination.PaginationSerializer', 'DEFAULT_PARSER_CLASSES': ( - 'rest_framework_ember.parsers.EmberJSONParser', + 'rest_framework_ember.parsers.JSONParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser' ), @@ -116,8 +116,6 @@ override ``settings.REST_FRAMEWORK``:: ), } - - If ``PAGINATE_BY`` is set the renderer will return a ``meta`` object with record count and the next and previous links. Django Rest Framework looks for the ``page`` GET parameter by default allowing you to make requests for @@ -141,6 +139,61 @@ the ``resource_name`` property is required on the class:: permission_classes = (permissions.IsAuthenticated, ) +Ember Data <-> Rest Framework Format Conversion +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +*(camelization/underscore/pluralize)* + +This package includes the optional ability to automatically convert json requests +and responses from the Ember Data camelCase to python/rest_framework's preferred +underscore. Additionally resource names can be pluralized if more than one object +is included in a serialized response as Ember Data expects. To hook this up, +include the following in your project settings:: + + REST_EMBER_FORMAT_KEYS = True + REST_EMBER_PLURALIZE_KEYS = True + + +Example - Without format conversion:: + + { + "identity": [ + { + "id": 1, + "username": "john", + "first_name": "John", + "last_name": "Coltrane" + }, + { + "id": 2, + "username": "frodo", + "first_name": "Bilbo", + "last_name": "Baggins" + }, + ], + ... + } + +Example - With format conversion:: + + { + "identities": [ + { + "id": 1, + "username": "john", + "firstName": "John", + "lastName": "Coltrane" + }, + { + "id": 2, + "username": "frodo", + "firstName": "Bilbo", + "lastName": "Baggins" + }, + ], + ... + } + + Managing the trailing slash ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/example/api/api.py b/example/api/api.py index ca46e047..270fc101 100644 --- a/example/api/api.py +++ b/example/api/api.py @@ -62,15 +62,15 @@ class UserEmber(User): resource_name = 'data' renderer_classes = (renderers.JSONRenderer, ) - parser_classes = (parsers.EmberJSONParser, ) + parser_classes = (parsers.JSONParser, ) class EmberUserModelViewSet(viewsets.ModelViewSet): - model = auth_models.User + queryset = auth_models.User.objects.all() serializer_class = IdentitySerializer allowed_methods = ['GET', 'POST', 'PUT', ] renderer_classes = (renderers.JSONRenderer, ) - parser_classes = (parsers.EmberJSONParser, ) + parser_classes = (parsers.JSONParser, ) class MultipleIDMixinUserModelViewSet(mixins.MultipleIDMixin, diff --git a/example/settings.py b/example/settings.py index ae6c0286..1474fb72 100644 --- a/example/settings.py +++ b/example/settings.py @@ -38,9 +38,9 @@ 'PAGINATE_BY_PARAM': 'page_size', 'MAX_PAGINATE_BY': 100, 'DEFAULT_PAGINATION_SERIALIZER_CLASS': - 'rest_framework_ember.pagination.EmberPaginationSerializer', + 'rest_framework_ember.pagination.PaginationSerializer', 'DEFAULT_PARSER_CLASSES': ( - # 'rest_framework_ember.parsers.EmberJSONParser', + # 'rest_framework_ember.parsers.JSONParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser' ), @@ -49,7 +49,6 @@ 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ), - 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S', } diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py new file mode 100644 index 00000000..1aa8b0d3 --- /dev/null +++ b/example/tests/test_format_keys.py @@ -0,0 +1,76 @@ +import json + +from example.tests import TestBase + +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse, reverse_lazy +from django.conf import settings + + +class FormatKeysSetTests(TestBase): + """ + Test that camelization and underscoring of key names works if they are activated. + """ + list_url = reverse_lazy('user-list') + + def setUp(self): + super(FormatKeysSetTests, self).setUp() + self.detail_url = reverse('user-detail', kwargs={'pk': self.miles.pk}) + + # Set the format keys settings. + setattr(settings, 'REST_EMBER_FORMAT_KEYS', True) + setattr(settings, 'REST_EMBER_PLURALIZE_KEYS', True) + + def tearDown(self): + # Remove the format keys settings. + delattr(settings, 'REST_EMBER_FORMAT_KEYS') + delattr(settings, 'REST_EMBER_PLURALIZE_KEYS') + + + def test_camelization(self): + """ + Test that camelization works. + """ + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, 200) + + user = get_user_model().objects.all()[0] + expected = { + u'user': [{ + u'id': user.pk, + u'firstName': user.first_name, + u'lastName': user.last_name, + u'email': user.email + }] + } + + json_content = json.loads(response.content) + meta = json_content.get('meta') + + self.assertEquals(expected.get('user'), json_content.get('user')) + self.assertEqual('http://testserver/user-viewset/?page=2', meta.get('nextLink')) + + def test_pluralization(self): + """ + Test that the key name is pluralized. + """ + response = self.client.get(self.list_url, {'page_size': 2}) + self.assertEqual(response.status_code, 200) + + users = get_user_model().objects.all() + expected = { + u'users': [{ + u'id': users[0].pk, + u'firstName': users[0].first_name, + u'lastName': users[0].last_name, + u'email': users[0].email + },{ + u'id': users[1].pk, + u'firstName': users[1].first_name, + u'lastName': users[1].last_name, + u'email': users[1].email + }] + } + + json_content = json.loads(response.content) + self.assertEquals(expected.get('users'), json_content.get('users')) diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index 36324d8d..95ec976c 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -1,4 +1,3 @@ - import json from example.tests import TestBase from django.core.urlresolvers import reverse @@ -49,3 +48,4 @@ def test_ember_expected_renderer(self): } ) + diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 270d7164..0c544d62 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -1,7 +1,7 @@ - - import json + from example.tests import TestBase + from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse, reverse_lazy from django.conf import settings @@ -9,9 +9,11 @@ class ModelViewSetTests(TestBase): """ - Test usage with ModelViewSets + Test usage with ModelViewSets, also tests pluralization, camelization, + and underscore. - [, [^/]+)/$>] + [, + [^/]+)/$>] """ list_url = reverse_lazy('user-list') @@ -21,29 +23,31 @@ def setUp(self): def test_key_in_list_result(self): """ - Ensure the result has a "user" key since that is the name of the model + Ensure the result has a 'user' key since that is the name of the model """ response = self.client.get(self.list_url) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[0] - expected = {"user": [{ - 'id': user.pk, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email - }]} + expected = { + u'user': [{ + u'id': user.pk, + u'first_name': user.first_name, + u'last_name': user.last_name, + u'email': user.email + }] + } json_content = json.loads(response.content) - meta = json_content.get("meta") + meta = json_content.get('meta') self.assertEquals(expected.get('user'), json_content.get('user')) self.assertEquals(meta.get('count', 0), get_user_model().objects.count()) - self.assertEquals(meta.get("next"), 2) + self.assertEquals(meta.get('next'), 2) self.assertEqual('http://testserver/user-viewset/?page=2', - meta.get("next_link")) - self.assertEqual(meta.get("page"), 1) + meta.get('next_link')) + self.assertEqual(meta.get('page'), 1) def test_page_two_in_list_result(self): """ @@ -53,72 +57,75 @@ def test_page_two_in_list_result(self): self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[1] - expected = {"user": [{ - 'id': user.pk, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email - }]} + expected = { + u'user': [{ + u'id': user.pk, + u'first_name': user.first_name, + u'last_name': user.last_name, + u'email': user.email + }] + } json_content = json.loads(response.content) - meta = json_content.get("meta") + meta = json_content.get('meta') self.assertEquals(expected.get('user'), json_content.get('user')) self.assertEquals(meta.get('count', 0), get_user_model().objects.count()) - self.assertIsNone(meta.get("next")) - self.assertIsNone(meta.get("next_link")) - self.assertEqual(meta.get("previous"), 1) + self.assertIsNone(meta.get('next')) + self.assertIsNone(meta.get('next_link')) + self.assertEqual(meta.get('previous'), 1) self.assertEqual('http://testserver/user-viewset/?page=1', - meta.get("previous_link")) - self.assertEqual(meta.get("page"), 2) + meta.get('previous_link')) + self.assertEqual(meta.get('page'), 2) def test_page_range_in_list_result(self): """ - Ensure that the range of a page can be changed from the client. + Ensure that the range of a page can be changed from the client, + tests pluralization as two objects means it converts ``user`` to + ``users``. """ response = self.client.get(self.list_url, {'page_size': 2}) self.assertEqual(response.status_code, 200) users = get_user_model().objects.all() - expected = {"user": [ - { - 'id': users[0].pk, - 'first_name': users[0].first_name, - 'last_name': users[0].last_name, - 'email': users[0].email - }, - { - 'id': users[1].pk, - 'first_name': users[1].first_name, - 'last_name': users[1].last_name, - 'email': users[1].email - }]} + expected = { + u'users': [{ + u'id': users[0].pk, + u'first_name': users[0].first_name, + u'last_name': users[0].last_name, + u'email': users[0].email + },{ + u'id': users[1].pk, + u'first_name': users[1].first_name, + u'last_name': users[1].last_name, + u'email': users[1].email + }] + } json_content = json.loads(response.content) - meta = json_content.get("meta") - self.assertEquals(expected.get('user'), json_content.get('user')) + meta = json_content.get('meta') + self.assertEquals(expected.get('users'), json_content.get('user')) self.assertEquals(meta.get('count', 0), get_user_model().objects.count()) def test_key_in_detail_result(self): """ - Ensure the result has a "user" key. + Ensure the result has a 'user' key. """ response = self.client.get(self.detail_url) self.assertEqual(response.status_code, 200) result = json.loads(response.content) expected = { - 'user': { - 'id': self.miles.pk, - 'first_name': self.miles.first_name, - 'last_name': self.miles.last_name, - 'email': self.miles.email + u'user': { + u'id': self.miles.pk, + u'first_name': self.miles.first_name, + u'last_name': self.miles.last_name, + u'email': self.miles.email } } - self.assertEqual(result, expected) def test_key_in_post(self): @@ -127,11 +134,11 @@ def test_key_in_post(self): """ self.client.login(username='miles', password='pw') data = { - 'user': { - 'id': self.miles.pk, - 'first_name': self.miles.first_name, - 'last_name': self.miles.last_name, - 'email': 'miles@trumpet.org' + u'user': { + u'id': self.miles.pk, + u'first_name': self.miles.first_name, + u'last_name': self.miles.last_name, + u'email': 'miles@trumpet.org' } } response = self.client.put(self.detail_url, data=data, format='json') @@ -145,4 +152,3 @@ def test_key_in_post(self): self.assertEqual( get_user_model().objects.get(pk=self.miles.pk).email, 'miles@trumpet.org') - diff --git a/example/tests/test_multiple_id_mixin.py b/example/tests/test_multiple_id_mixin.py index 8a9a4f6d..1b020637 100644 --- a/example/tests/test_multiple_id_mixin.py +++ b/example/tests/test_multiple_id_mixin.py @@ -1,5 +1,3 @@ - - import json from example.tests import TestBase from django.contrib.auth import get_user_model @@ -71,3 +69,4 @@ def test_multiple_ids_in_query_params(self): meta.get("next_link")) self.assertEqual(meta.get("page"), 1) + diff --git a/rest_framework_ember/pagination.py b/rest_framework_ember/pagination.py index 5f8af6d5..dab6e929 100644 --- a/rest_framework_ember/pagination.py +++ b/rest_framework_ember/pagination.py @@ -5,13 +5,13 @@ from rest_framework_ember.utils import get_resource_name -class NextPageLinkField(serializers.Field): +class NextPageLinkField(serializers.ReadOnlyField): """ Field that returns a link to the next page in paginated results. """ page_field = 'page' - def to_native(self, value): + def to_representation(self, value): if not value.has_next(): return None page = value.next_page_number() @@ -20,25 +20,25 @@ def to_native(self, value): return replace_query_param(url, self.page_field, page) -class NextPageField(serializers.Field): +class NextPageField(serializers.ReadOnlyField): """ Field that returns a link to the next page in paginated results. """ page_field = 'page' - def to_native(self, value): + def to_representation(self, value): if not value.has_next(): return None return value.next_page_number() -class PreviousPageLinkField(serializers.Field): +class PreviousPageLinkField(serializers.ReadOnlyField): """ Field that returns a link to the previous page in paginated results. """ page_field = 'page' - def to_native(self, value): + def to_representation(self, value): if not value.has_previous(): return None page = value.previous_page_number() @@ -47,34 +47,42 @@ def to_native(self, value): return replace_query_param(url, self.page_field, page) -class PreviousPageField(serializers.Field): +class PreviousPageField(serializers.ReadOnlyField): """ Field that returns a link to the previous page in paginated results. """ page_field = 'page' - def to_native(self, value): + def to_representation(self, value): if not value.has_previous(): return None return value.previous_page_number() -class PageField(serializers.Field): +class PageField(serializers.ReadOnlyField): """ Field that returns a link to the previous page in paginated results. """ page_field = 'page' - def to_native(self, value): + def to_representation(self, value): return value.number -class EmberPaginationSerializer(pagination.BasePaginationSerializer): +class PaginationSerializer(pagination.BasePaginationSerializer): next = NextPageField(source='*') next_link = NextPageLinkField(source='*') page = PageField(source='*') previous = PreviousPageField(source='*') previous_link = PreviousPageLinkField(source='*') - count = serializers.Field(source='paginator.count') + count = serializers.ReadOnlyField(source='paginator.count') + total = serializers.ReadOnlyField(source='paginator.num_pages') + + +class EmberPaginationSerializer(PaginationSerializer): + """ + Backwards compatibility for name change + """ + pass diff --git a/rest_framework_ember/parsers.py b/rest_framework_ember/parsers.py index db87e812..b9e3ee32 100644 --- a/rest_framework_ember/parsers.py +++ b/rest_framework_ember/parsers.py @@ -1,12 +1,13 @@ """ Parsers """ -from rest_framework.parsers import JSONParser - +from rest_framework import parsers from rest_framework_ember.utils import get_resource_name +from .utils import format_keys -class EmberJSONParser(JSONParser): + +class JSONParser(parsers.JSONParser): """ By default, EmberJS sends a payload that looks like the following:: @@ -21,14 +22,19 @@ class EmberJSONParser(JSONParser): So we can work with the grain on both Ember and RestFramework, Do some tweaks to the payload so DRF gets what it expects. """ - def parse(self, stream, media_type=None, parser_context=None): """ Parses the incoming bytestream as JSON and returns the resulting data """ - result = super(EmberJSONParser, self).parse( - stream, media_type=None, parser_context=None) + result = super(JSONParser, self).parse(stream, media_type=None, + parser_context=None) + resource = result.get(get_resource_name(parser_context.get('view', None))) + return format_keys(resource, 'underscore') - resource_name = get_resource_name(parser_context.get('view', None)) - return result.get(resource_name) + +class EmberJSONParser(JSONParser): + """ + Backward compatability for our first uniquely named parser + """ + pass diff --git a/rest_framework_ember/renderers.py b/rest_framework_ember/renderers.py index 8bc22db3..78f314de 100644 --- a/rest_framework_ember/renderers.py +++ b/rest_framework_ember/renderers.py @@ -1,8 +1,12 @@ -import copy -from rest_framework import renderers +import inflection + +from django.conf import settings +from rest_framework import renderers from rest_framework_ember.utils import get_resource_name +from .utils import format_keys, format_resource_name + class JSONRenderer(renderers.JSONRenderer): """ @@ -18,19 +22,26 @@ class JSONRenderer(renderers.JSONRenderer): """ def render(self, data, accepted_media_type=None, renderer_context=None): view = renderer_context.get('view') - resource_name = get_resource_name(view) if resource_name == False: return super(JSONRenderer, self).render( data, accepted_media_type, renderer_context) + data = format_keys(data, 'camelize') + try: - data_copy = copy.copy(data) - content = data_copy.pop('results') - data = {resource_name : content, "meta" : data_copy} + content = data.pop('results') + resource_name = format_resource_name(content, resource_name) + data = {resource_name : content, "meta" : data} except (TypeError, KeyError, AttributeError) as e: + + # Default behavior + if not resource_name == 'data': + format_keys(data, 'camelize') + resource_name = format_resource_name(data, resource_name) + data = {resource_name : data} + return super(JSONRenderer, self).render( data, accepted_media_type, renderer_context) - diff --git a/rest_framework_ember/utils.py b/rest_framework_ember/utils.py index 74da41f9..4c6f6d36 100644 --- a/rest_framework_ember/utils.py +++ b/rest_framework_ember/utils.py @@ -1,22 +1,22 @@ -""" -Resource name utilities. -""" +import inflection + +from django.conf import settings + def get_resource_name(view): """ - Return the name of a resource + Return the name of a resource. """ try: - # is the resource name set directly on the view? + # Check the view resource_name = getattr(view, 'resource_name') except AttributeError: try: - # was it set in the serializer Meta class? + # Check the meta class resource_name = getattr(view, 'serializer_class')\ .Meta.resource_name except AttributeError: - # camelCase the name of the model if it hasn't been set - # in either of the other places + # Use the model try: name = resource_name = getattr(view, 'serializer_class')\ .Meta.model.__name__ @@ -26,7 +26,45 @@ def get_resource_name(view): except AttributeError: name = view.__class__.__name__ + name = format_keys(name) resource_name = name[:1].lower() + name[1:] return resource_name + +def format_keys(obj, format_type=None): + """ + Takes either a dict or list and returns it with camelized keys only if + REST_EMBER_FORMAT_KEYS is set. + + :format_type: Either 'camelize' or 'underscore' + """ + if getattr(settings, 'REST_EMBER_FORMAT_KEYS', False)\ + and format_type in ('camelize', 'underscore'): + + if isinstance(obj, dict): + formatted = {} + for key, value in obj.items(): + if format_type == 'camelize': + formatted[inflection.camelize(key, False)]\ + = format_keys(value, format_type) + elif format_type == 'underscore': + formatted[inflection.underscore(key)]\ + = format_keys(value, format_type) + return formatted + if isinstance(obj, list): + return [format_keys(item, format_type) for item in obj] + else: + return obj + else: + return obj + + +def format_resource_name(obj, name): + """ + Pluralize the resource name if more than one object in results. + """ + if getattr(settings, 'REST_EMBER_PLURALIZE_KEYS', False) and isinstance(obj, list): + return inflection.pluralize(name) if len(obj) > 1 else name + else: + return name diff --git a/setup.py b/setup.py index 36bb317a..7d711b4f 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ def get_readme(): license='BSD', keywords="EmberJS Django REST", packages=find_packages(), - install_requires=['django', 'djangorestframework < 3.0.0' ], + install_requires=['django', 'djangorestframework >= 3.0.0', 'inflection' ], platforms=['any'], classifiers=[ 'Development Status :: 4 - Beta',