diff --git a/dynamic_rest/fields.py b/dynamic_rest/fields.py index 06547a9c..a91fe44e 100644 --- a/dynamic_rest/fields.py +++ b/dynamic_rest/fields.py @@ -125,6 +125,8 @@ def __init__( self.embed = embed if '.' in self.kwargs.get('source', ''): raise Exception('Nested relationships are not supported') + if 'link' in kwargs: + self.link = kwargs.pop('link') super(DynamicRelationField, self).__init__(**kwargs) self.kwargs['many'] = many diff --git a/dynamic_rest/serializer_helpers.py b/dynamic_rest/serializer_helpers.py new file mode 100644 index 00000000..39e52e35 --- /dev/null +++ b/dynamic_rest/serializer_helpers.py @@ -0,0 +1,29 @@ +def merge_link_object(serializer, data, instance): + """Add a 'links' attribute to the data that maps field names to URLs. + + NOTE: This is the format that Ember Data supports, but alternative + implementations are possible to support other formats. + """ + + link_object = {} + + link_fields = serializer.get_link_fields() + for name, field in link_fields.iteritems(): + # For included fields, omit link if there's no data. + if name in data and not data[name]: + continue + + # Default to DREST-generated relation endpoints. + link = getattr(field, 'link', "/%s/%s/%s/" % ( + serializer.get_plural_name(), + instance.pk, + name + )) + if callable(link): + link = link(name, field, data, instance) + + link_object[name] = link + + if link_object: + data['links'] = link_object + return data diff --git a/dynamic_rest/serializers.py b/dynamic_rest/serializers.py index 3acdd169..cb47adad 100644 --- a/dynamic_rest/serializers.py +++ b/dynamic_rest/serializers.py @@ -1,15 +1,20 @@ import copy +from django.conf import settings from django.db import models from dynamic_rest.bases import DynamicSerializerBase from dynamic_rest.fields import DynamicRelationField from dynamic_rest.processors import SideloadingProcessor +from dynamic_rest.serializer_helpers import merge_link_object from dynamic_rest.wrappers import TaggedDict from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList from rest_framework import serializers, fields, exceptions +dynamic_settings = getattr(settings, 'DYNAMIC_REST', {}) + + class DynamicListSerializer(serializers.ListSerializer): def to_representation(self, data): @@ -25,6 +30,9 @@ def get_name(self): def get_plural_name(self): return self.child.get_plural_name() + def id_only(self): + return self.child.id_only() + @property def data(self): if not hasattr(self, '_sideloaded_data'): @@ -176,6 +184,15 @@ def get_all_fields(self): field.parent = self return self._all_fields + def _get_deferred_field_names(self, serializer_fields): + """Return set of deferred field names.""" + meta_deferred = set(getattr(self.Meta, 'deferred_fields', [])) + return { + name for name, field in serializer_fields.iteritems() + if getattr(field, 'deferred', None) is True or name in + meta_deferred + } + def get_fields(self): """Returns the serializer's field set. @@ -191,14 +208,7 @@ def get_fields(self): serializer_fields = copy.deepcopy(all_fields) request_fields = self.request_fields - - # determine fields that are deferred by default - meta_deferred = set(getattr(self.Meta, 'deferred_fields', [])) - deferred = { - name for name, field in serializer_fields.iteritems() - if getattr(field, 'deferred', None) is True or name in - meta_deferred - } + deferred = self._get_deferred_field_names(serializer_fields) # apply request overrides if request_fields: @@ -234,6 +244,23 @@ def get_fields(self): return serializer_fields + def get_link_fields(self): + """Construct dict of name:field for linkable fields.""" + if not hasattr(self, '_link_fields'): + all_fields = self.get_all_fields() + self._link_fields = { + name: field for name, field in all_fields.iteritems() + if isinstance(field, DynamicRelationField) + and getattr(field, 'link', True) + and not ( + # Skip sideloaded fields + name in self.fields + and not field.serializer.id_only() + ) + } + + return self._link_fields + def to_representation(self, instance): if self.id_only(): return instance.pk @@ -242,6 +269,12 @@ def to_representation(self, instance): WithDynamicSerializerMixin, self).to_representation(instance) + if getattr(settings, 'DYNAMIC_REST', {}).get('ENABLE_LINKS', True): + # TODO: Make this function configurable to support other + # formats like JSON API link objects. + representation = merge_link_object( + self, representation, instance) + # tag the representation with the serializer and instance return TaggedDict( representation, diff --git a/tests/serializers.py b/tests/serializers.py index 62f39f67..d7f3624a 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -12,9 +12,14 @@ DynamicMethodField, DynamicRelationField, CountField, DynamicField) +def backup_home_link(name, field, data, obj): + return "/locations/%s/?include[]=address" % obj.backup_home_id + + class CatSerializer(DynamicModelSerializer): - home = DynamicRelationField('LocationSerializer') - backup_home = DynamicRelationField('LocationSerializer') + home = DynamicRelationField('LocationSerializer', link=None) + backup_home = DynamicRelationField( + 'LocationSerializer', link=backup_home_link) foobar = DynamicRelationField( 'LocationSerializer', source='hunting_grounds', many=True) diff --git a/tests/settings.py b/tests/settings.py index 39a8ad0e..c47d2731 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -31,3 +31,7 @@ ROOT_URLCONF = 'tests.urls' BASE_DIR = os.path.dirname(__file__) + +DYNAMIC_REST = { + 'ENABLE_LINKS': True, +} diff --git a/tests/test_api.py b/tests/test_api.py index e436bb94..570cb1da 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,10 @@ import json from django.db import connection +from django.conf import settings from rest_framework.test import APITestCase from tests.setup import create_fixture from tests.models import ( + Cat, Location, Group, Profile, @@ -15,6 +17,7 @@ class TestUsersAPI(APITestCase): def setUp(self): self.fixture = create_fixture() self.maxDiff = None + settings.DYNAMIC_REST['ENABLE_LINKS'] = False def testDefault(self): with self.assertNumQueries(1): @@ -720,3 +723,62 @@ def testGetEmbedded(self): self.assertEqual(content['user_location']['location']['name'], '0') self.assertTrue(isinstance(groups[0], dict)) self.assertTrue(isinstance(location, dict)) + + +class TestLinks(APITestCase): + + def setUp(self): + self.fixture = create_fixture() + settings.DYNAMIC_REST['ENABLE_LINKS'] = True + + def test_deferred_relations_have_links(self): + r = self.client.get('/cats/1/') + self.assertEqual(200, r.status_code) + content = json.loads(r.content) + + cat = content['cat'] + self.assertTrue('links' in cat) + + # 'home' has link=None set so should not have a link object + self.assertTrue('home' not in cat['links']) + + # test for default link (auto-generated relation endpoint) + self.assertEqual(cat['links']['foobar'], '/cats/1/foobar/') + + # test for dynamically generated link URL + cat1 = Cat.objects.get(pk=1) + self.assertEqual( + cat['links']['backup_home'], + '/locations/%s/?include[]=address' % cat1.backup_home.pk + ) + + def test_including_empty_relation_hides_link(self): + r = self.client.get('/cats/1/?include[]=foobar') + self.assertEqual(200, r.status_code) + content = json.loads(r.content) + + # 'foobar' is included but empty, so don't return a link + cat = content['cat'] + self.assertFalse(cat['foobar']) + self.assertFalse('foobar' in cat['links']) + + def test_including_relation_returns_link(self): + url = '/cats/1/?include=backup_home' + r = self.client.get(url) + self.assertEqual(200, r.status_code) + content = json.loads(r.content) + + cat = content['cat'] + self.assertTrue('backup_home' in cat['links']) + self.assertTrue(cat['links']['backup_home']) + + def test_sideloading_relation_hides_link(self): + url = '/cats/1/?include[]=backup_home.' + r = self.client.get(url) + self.assertEqual(200, r.status_code) + content = json.loads(r.content) + + cat = content['cat'] + self.assertTrue('backup_home' in cat) + self.assertTrue('locations' in content) # check for sideload + self.assertFalse('backup_home' in cat['links']) # no link diff --git a/tests/test_serializers.py b/tests/test_serializers.py index d33203ae..359a6a85 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from django.conf import settings from django.test import TestCase from dynamic_rest.fields import DynamicRelationField @@ -21,6 +22,7 @@ class TestDynamicSerializer(TestCase): def setUp(self): self.fixture = create_fixture() self.maxDiff = None + settings.DYNAMIC_REST['ENABLE_LINKS'] = False def testDefault(self): serializer = UserSerializer( diff --git a/tests/urls.py b/tests/urls.py index 519b1384..dc74f553 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -7,6 +7,7 @@ router.register(r'groups', viewsets.GroupViewSet) router.register(r'profiles', viewsets.ProfileViewSet) router.register(r'locations', viewsets.LocationViewSet) +router.register(r'cats', viewsets.CatViewSet) router.register(r'user_locations', viewsets.UserLocationViewSet) urlpatterns = patterns('', diff --git a/tests/viewsets.py b/tests/viewsets.py index 998807b2..8502baf5 100644 --- a/tests/viewsets.py +++ b/tests/viewsets.py @@ -1,5 +1,6 @@ from dynamic_rest.viewsets import DynamicModelViewSet from tests.serializers import ( + CatSerializer, LocationSerializer, GroupSerializer, ProfileSerializer, @@ -7,6 +8,7 @@ UserLocationSerializer ) from tests.models import ( + Cat, Group, Location, Profile, @@ -73,3 +75,8 @@ class ProfileViewSet(DynamicModelViewSet): model = Profile serializer_class = ProfileSerializer queryset = Profile.objects.all() + + +class CatViewSet(DynamicModelViewSet): + serializer_class = CatSerializer + queryset = Cat.objects.all()