Skip to content

Commit

Permalink
Merge pull request #36 from AltSchool/feature/auto-link-objects
Browse files Browse the repository at this point in the history
auto-generate link objects for non-sideloaded relations
  • Loading branch information
ryochiji committed Aug 17, 2015
2 parents 78c531d + fe63f88 commit 4b53b54
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 10 deletions.
2 changes: 2 additions & 0 deletions dynamic_rest/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions dynamic_rest/serializer_helpers.py
Original file line number Diff line number Diff line change
@@ -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
49 changes: 41 additions & 8 deletions dynamic_rest/serializers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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'):
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions tests/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@
ROOT_URLCONF = 'tests.urls'

BASE_DIR = os.path.dirname(__file__)

DYNAMIC_REST = {
'ENABLE_LINKS': True,
}
62 changes: 62 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import OrderedDict
from django.conf import settings
from django.test import TestCase

from dynamic_rest.fields import DynamicRelationField
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('',
Expand Down
7 changes: 7 additions & 0 deletions tests/viewsets.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from dynamic_rest.viewsets import DynamicModelViewSet
from tests.serializers import (
CatSerializer,
LocationSerializer,
GroupSerializer,
ProfileSerializer,
UserSerializer,
UserLocationSerializer
)
from tests.models import (
Cat,
Group,
Location,
Profile,
Expand Down Expand Up @@ -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()

0 comments on commit 4b53b54

Please sign in to comment.