Skip to content

Commit 21c8911

Browse files
committed
Merge pull request #104 from django-json-api/feature/ResourceRelatedField
Add ResourceRelatedField support
2 parents 7ba1ab6 + 61ca12d commit 21c8911

File tree

5 files changed

+175
-41
lines changed

5 files changed

+175
-41
lines changed

example/tests/test_relations.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import absolute_import
22

33
from django.utils import timezone
4-
54
from rest_framework import serializers
65

76
from . import TestBase
7+
from rest_framework_json_api.exceptions import Conflict
88
from rest_framework_json_api.utils import format_relation_name
99
from example.models import Blog, Entry, Comment, Author
1010
from rest_framework_json_api.relations import ResourceRelatedField
@@ -74,15 +74,17 @@ def test_deserialize_primitive_data_blog(self):
7474
self.assertEqual(serializer.validated_data['blog'], self.blog)
7575

7676
def test_validation_fails_for_wrong_type(self):
77-
serializer = BlogFKSerializer(data={
78-
'blog': {
79-
'type': 'Entries',
80-
'id': str(self.blog.id)
77+
with self.assertRaises(Conflict) as cm:
78+
serializer = BlogFKSerializer(data={
79+
'blog': {
80+
'type': 'Entries',
81+
'id': str(self.blog.id)
82+
}
8183
}
82-
}
83-
)
84-
85-
self.assertFalse(serializer.is_valid())
84+
)
85+
serializer.is_valid()
86+
the_exception = cm.exception
87+
self.assertEqual(the_exception.status_code, 409)
8688

8789
def test_serialize_many_to_many_relation(self):
8890
serializer = EntryModelSerializer(instance=self.entry)

rest_framework_json_api/parsers.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ def parse(self, stream, media_type=None, parser_context=None):
4848
raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object')
4949

5050
return data
51+
52+
request = parser_context.get('request')
53+
5154
# Check for inconsistencies
5255
resource_name = utils.get_resource_name(parser_context)
53-
if data.get('type') != resource_name:
56+
if data.get('type') != resource_name and request.method in ('PUT', 'POST', 'PATCH'):
5457
raise exceptions.Conflict(
5558
"The resource object's type ({data_type}) is not the type "
5659
"that constitute the collection represented by the endpoint ({resource_type}).".format(
@@ -72,9 +75,9 @@ def parse(self, stream, media_type=None, parser_context=None):
7275
for field_name, field_data in relationships.items():
7376
field_data = field_data.get('data')
7477
if isinstance(field_data, dict):
75-
parsed_relationships[field_name] = field_data.get('id')
78+
parsed_relationships[field_name] = field_data
7679
elif isinstance(field_data, list):
77-
parsed_relationships[field_name] = list(relation.get('id') for relation in field_data)
80+
parsed_relationships[field_name] = list(relation for relation in field_data)
7881

7982
# Construct the return data
8083
parsed_data = {'id': data_id}

rest_framework_json_api/relations.py

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from rest_framework.exceptions import ValidationError
2+
from rest_framework.fields import MISSING_ERROR_MESSAGE
23
from rest_framework.relations import *
3-
from rest_framework_json_api.utils import format_relation_name, get_related_resource_type, \
4-
get_resource_type_from_queryset, get_resource_type_from_instance
54
from django.utils.translation import ugettext_lazy as _
65

6+
from rest_framework_json_api.exceptions import Conflict
7+
from rest_framework_json_api.utils import format_relation_name, Hyperlink, \
8+
get_resource_type_from_queryset, get_resource_type_from_instance
9+
710

811
class HyperlinkedRelatedField(HyperlinkedRelatedField):
912
"""
@@ -40,22 +43,108 @@ def to_internal_value(self, data):
4043

4144

4245
class ResourceRelatedField(PrimaryKeyRelatedField):
46+
self_link_view_name = None
47+
related_link_view_name = None
48+
related_link_lookup_field = 'pk'
49+
4350
default_error_messages = {
4451
'required': _('This field is required.'),
4552
'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'),
46-
'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'),
53+
'incorrect_type': _('Incorrect type. Expected resource identifier object, received {data_type}.'),
4754
'incorrect_relation_type': _('Incorrect relation type. Expected {relation_type}, received {received_type}.'),
55+
'no_match': _('Invalid hyperlink - No URL match.'),
4856
}
4957

58+
def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwargs):
59+
if self_link_view_name is not None:
60+
self.self_link_view_name = self_link_view_name
61+
if related_link_view_name is not None:
62+
self.related_link_view_name = related_link_view_name
63+
64+
self.related_link_lookup_field = kwargs.pop('related_link_lookup_field', self.related_link_lookup_field)
65+
self.related_link_url_kwarg = kwargs.pop('related_link_url_kwarg', self.related_link_lookup_field)
66+
67+
# We include this simply for dependency injection in tests.
68+
# We can't add it as a class attributes or it would expect an
69+
# implicit `self` argument to be passed.
70+
self.reverse = reverse
71+
72+
super(ResourceRelatedField, self).__init__(**kwargs)
73+
74+
def use_pk_only_optimization(self):
75+
# We need the real object to determine its type...
76+
return False
77+
78+
def conflict(self, key, **kwargs):
79+
"""
80+
A helper method that simply raises a validation error.
81+
"""
82+
try:
83+
msg = self.error_messages[key]
84+
except KeyError:
85+
class_name = self.__class__.__name__
86+
msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key)
87+
raise AssertionError(msg)
88+
message_string = msg.format(**kwargs)
89+
raise Conflict(message_string)
90+
91+
def get_url(self, name, view_name, kwargs, request):
92+
"""
93+
Given a name, view name and kwargs, return the URL that hyperlinks to the object.
94+
95+
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
96+
attributes are not configured to correctly match the URL conf.
97+
"""
98+
99+
# Return None if the view name is not supplied
100+
if not view_name:
101+
return None
102+
103+
# Return the hyperlink, or error if incorrectly configured.
104+
try:
105+
url = self.reverse(view_name, kwargs=kwargs, request=request)
106+
except NoReverseMatch:
107+
msg = (
108+
'Could not resolve URL for hyperlinked relationship using '
109+
'view name "%s".'
110+
)
111+
raise ImproperlyConfigured(msg % view_name)
112+
113+
if url is None:
114+
return None
115+
116+
return Hyperlink(url, name)
117+
118+
def get_links(self):
119+
request = self.context.get('request', None)
120+
view = self.context.get('view', None)
121+
return_data = OrderedDict()
122+
self_kwargs = view.kwargs.copy()
123+
self_kwargs.update({'related_field': self.field_name if self.field_name else self.parent.field_name})
124+
self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request)
125+
126+
related_kwargs = {self.related_link_url_kwarg: view.kwargs[self.related_link_lookup_field]}
127+
related_link = self.get_url('related', self.related_link_view_name, related_kwargs, request)
128+
129+
if self_link:
130+
return_data.update({'self': self_link})
131+
if related_link:
132+
return_data.update({'related': related_link})
133+
return return_data
134+
50135
def to_internal_value(self, data):
51136
expected_relation_type = get_resource_type_from_queryset(self.queryset)
137+
if not isinstance(data, dict):
138+
self.fail('incorrect_type', data_type=type(data).__name__)
52139
if data['type'] != expected_relation_type:
53-
self.fail('incorrect_relation_type', relation_type=expected_relation_type, received_type=data['type'])
140+
self.conflict('incorrect_relation_type', relation_type=expected_relation_type, received_type=data['type'])
54141
return super(ResourceRelatedField, self).to_internal_value(data['id'])
55142

56143
def to_representation(self, value):
57-
return {
58-
'type': format_relation_name(get_resource_type_from_instance(value)),
59-
'id': str(value.pk)
60-
}
144+
if getattr(self, 'pk_field', None) is not None:
145+
pk = self.pk_field.to_representation(value.pk)
146+
else:
147+
pk = value.pk
148+
149+
return OrderedDict([('type', format_relation_name(get_resource_type_from_instance(value))), ('id', str(pk))])
61150

rest_framework_json_api/utils.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from rest_framework.settings import api_settings
1212
from rest_framework.exceptions import APIException
1313

14+
1415
try:
1516
from rest_framework.compat import OrderedDict
1617
except ImportError:
@@ -237,6 +238,9 @@ def extract_attributes(fields, resource):
237238

238239

239240
def extract_relationships(fields, resource, resource_instance):
241+
# Avoid circular deps
242+
from rest_framework_json_api.relations import ResourceRelatedField
243+
240244
data = OrderedDict()
241245

242246
# Don't try to extract relationships from a non-existent resource
@@ -254,7 +258,7 @@ def extract_relationships(fields, resource, resource_instance):
254258

255259
try:
256260
relation_instance_or_manager = getattr(resource_instance, field_name)
257-
except AttributeError: # Skip fields defined on the serializer that don't correspond to a field on the model
261+
except AttributeError: # Skip fields defined on the serializer that don't correspond to a field on the model
258262
continue
259263

260264
relation_type = get_related_resource_type(field)
@@ -282,6 +286,20 @@ def extract_relationships(fields, resource, resource_instance):
282286
}})
283287
continue
284288

289+
if isinstance(field, ResourceRelatedField):
290+
# special case for ResourceRelatedField
291+
relation_data = {
292+
'data': resource.get(field_name)
293+
}
294+
295+
field_links = field.get_links()
296+
relation_data.update(
297+
{'links': field_links}
298+
if field_links else dict()
299+
)
300+
data.update({field_name: relation_data})
301+
continue
302+
285303
if isinstance(field, (PrimaryKeyRelatedField, HyperlinkedRelatedField)):
286304
relation_id = relation_instance_or_manager.pk if resource.get(field_name) else None
287305

@@ -299,6 +317,28 @@ def extract_relationships(fields, resource, resource_instance):
299317
continue
300318

301319
if isinstance(field, ManyRelatedField):
320+
321+
if isinstance(field.child_relation, ResourceRelatedField):
322+
# special case for ResourceRelatedField
323+
relation_data = {
324+
'data': resource.get(field_name)
325+
}
326+
327+
field_links = field.child_relation.get_links()
328+
relation_data.update(
329+
{'links': field_links}
330+
if field_links else dict()
331+
)
332+
relation_data.update(
333+
{
334+
'meta': {
335+
'count': len(resource.get(field_name))
336+
}
337+
}
338+
)
339+
data.update({field_name: relation_data})
340+
continue
341+
302342
relation_data = list()
303343
for related_object in relation_instance_or_manager.all():
304344
related_object_type = get_instance_or_manager_resource_type(relation_instance_or_manager)
@@ -395,3 +435,21 @@ def extract_included(fields, resource, resource_instance):
395435
)
396436

397437
return format_keys(included_data)
438+
439+
440+
class Hyperlink(six.text_type):
441+
"""
442+
A string like object that additionally has an associated name.
443+
We use this for hyperlinked URLs that may render as a named link
444+
in some contexts, or render as a plain URL in others.
445+
446+
Comes from Django REST framework 3.2
447+
https://github.com/tomchristie/django-rest-framework
448+
"""
449+
450+
def __new__(self, url, name):
451+
ret = six.text_type.__new__(self, url)
452+
ret.name = name
453+
return ret
454+
455+
is_hyperlink = True

rest_framework_json_api/views.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
from django.db.models import Model
44
from django.db.models.query import QuerySet
55
from django.db.models.manager import Manager
6-
from django.utils import six
76
from rest_framework import generics
87
from rest_framework.response import Response
98
from rest_framework.exceptions import NotFound, MethodNotAllowed
109
from rest_framework.reverse import reverse
1110

1211
from rest_framework_json_api.exceptions import Conflict
1312
from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer
14-
from rest_framework_json_api.utils import format_relation_name, get_resource_type_from_instance, OrderedDict
13+
from rest_framework_json_api.utils import format_relation_name, get_resource_type_from_instance, OrderedDict, Hyperlink
1514

1615

1716
class RelationshipView(generics.GenericAPIView):
@@ -28,29 +27,12 @@ def __init__(self, **kwargs):
2827

2928
def get_url(self, name, view_name, kwargs, request):
3029
"""
31-
Given an object, return the URL that hyperlinks to the object.
30+
Given a name, view name and kwargs, return the URL that hyperlinks to the object.
3231
3332
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
3433
attributes are not configured to correctly match the URL conf.
3534
"""
3635

37-
class Hyperlink(six.text_type):
38-
"""
39-
A string like object that additionally has an associated name.
40-
We use this for hyperlinked URLs that may render as a named link
41-
in some contexts, or render as a plain URL in others.
42-
43-
Comes from Django REST framework 3.2
44-
https://github.com/tomchristie/django-rest-framework
45-
"""
46-
47-
def __new__(self, url, name):
48-
ret = six.text_type.__new__(self, url)
49-
ret.name = name
50-
return ret
51-
52-
is_hyperlink = True
53-
5436
# Return None if the view name is not supplied
5537
if not view_name:
5638
return None

0 commit comments

Comments
 (0)