From 3127cf8cb4c57f8e7e98c47377b8ce6737b6d342 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Tue, 17 Jul 2018 12:52:16 +0300 Subject: [PATCH 01/11] Added SkipDataMixin, HyperLinkedMixin --- rest_framework_json_api/relations.py | 143 +++++++++++++++++++-------- rest_framework_json_api/renderers.py | 48 ++++----- 2 files changed, 127 insertions(+), 64 deletions(-) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 27644919..df14c536 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -7,8 +7,10 @@ from django.core.exceptions import ImproperlyConfigured from django.urls import NoReverseMatch from django.utils.translation import ugettext_lazy as _ -from rest_framework.fields import MISSING_ERROR_MESSAGE -from rest_framework.relations import MANY_RELATION_KWARGS, PrimaryKeyRelatedField +from rest_framework.fields import MISSING_ERROR_MESSAGE, SkipField +from rest_framework.relations import MANY_RELATION_KWARGS +from rest_framework.relations import ManyRelatedField as DRFManyRelatedField +from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.reverse import reverse from rest_framework.serializers import Serializer @@ -29,26 +31,31 @@ ] -class ResourceRelatedField(PrimaryKeyRelatedField): - _skip_polymorphic_optimization = True +class SkipDataMixin(object): + """ + This workaround skips "data" rendering for relationships + in order to save some sql queries and improve performance + """ + + def __init__(self, *args, **kwargs): + super(SkipDataMixin, self).__init__(*args, **kwargs) + + def get_attribute(self, instance): + raise SkipField + + def to_representation(self, *args): + raise NotImplementedError + + +class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): + pass + + +class HyperLinkedMixin(object): self_link_view_name = None related_link_view_name = None related_link_lookup_field = 'pk' - default_error_messages = { - 'required': _('This field is required.'), - 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), - 'incorrect_type': _( - 'Incorrect type. Expected resource identifier object, received {data_type}.' - ), - 'incorrect_relation_type': _( - 'Incorrect relation type. Expected {relation_type}, received {received_type}.' - ), - 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), - 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), - 'no_match': _('Invalid hyperlink - No URL match.'), - } - def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwargs): if self_link_view_name is not None: self.self_link_view_name = self_link_view_name @@ -62,34 +69,12 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar 'related_link_url_kwarg', self.related_link_lookup_field ) - # check for a model class that was passed in for the relation type - model = kwargs.pop('model', None) - if model: - self.model = model - # We include this simply for dependency injection in tests. # We can't add it as a class attributes or it would expect an # implicit `self` argument to be passed. self.reverse = reverse - super(ResourceRelatedField, self).__init__(**kwargs) - - def use_pk_only_optimization(self): - # We need the real object to determine its type... - return self.get_resource_type_from_included_serializer() is not None - - def conflict(self, key, **kwargs): - """ - A helper method that simply raises a validation error. - """ - try: - msg = self.error_messages[key] - except KeyError: - class_name = self.__class__.__name__ - msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key) - raise AssertionError(msg) - message_string = msg.format(**kwargs) - raise Conflict(message_string) + super(HyperLinkedMixin, self).__init__(**kwargs) def get_url(self, name, view_name, kwargs, request): """ @@ -140,6 +125,78 @@ def get_links(self, obj=None, lookup_field='pk'): return_data.update({'related': related_link}) return return_data + +class HyperLinkedRelatedField(HyperLinkedMixin, SkipDataMixin, RelatedField): + + @classmethod + def many_init(cls, *args, **kwargs): + """ + This method handles creating a parent `ManyRelatedField` instance + when the `many=True` keyword argument is passed. + + Typically you won't need to override this method. + + Note that we're over-cautious in passing most arguments to both parent + and child classes in order to try to cover the general case. If you're + overriding this method you'll probably want something much simpler, eg: + + @classmethod + def many_init(cls, *args, **kwargs): + kwargs['child'] = cls() + return CustomManyRelatedField(*args, **kwargs) + """ + list_kwargs = {'child_relation': cls(*args, **kwargs)} + for key in kwargs: + if key in MANY_RELATION_KWARGS: + list_kwargs[key] = kwargs[key] + return ManyRelatedFieldWithNoData(**list_kwargs) + + +class ResourceRelatedField(HyperLinkedMixin, PrimaryKeyRelatedField): + _skip_polymorphic_optimization = True + self_link_view_name = None + related_link_view_name = None + related_link_lookup_field = 'pk' + + default_error_messages = { + 'required': _('This field is required.'), + 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), + 'incorrect_type': _( + 'Incorrect type. Expected resource identifier object, received {data_type}.' + ), + 'incorrect_relation_type': _( + 'Incorrect relation type. Expected {relation_type}, received {received_type}.' + ), + 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), + 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), + 'no_match': _('Invalid hyperlink - No URL match.'), + } + + def __init__(self, **kwargs): + # check for a model class that was passed in for the relation type + model = kwargs.pop('model', None) + if model: + self.model = model + + super(ResourceRelatedField, self).__init__(**kwargs) + + def use_pk_only_optimization(self): + # We need the real object to determine its type... + return self.get_resource_type_from_included_serializer() is not None + + def conflict(self, key, **kwargs): + """ + A helper method that simply raises a validation error. + """ + try: + msg = self.error_messages[key] + except KeyError: + class_name = self.__class__.__name__ + msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key) + raise AssertionError(msg) + message_string = msg.format(**kwargs) + raise Conflict(message_string) + def to_internal_value(self, data): if isinstance(data, six.text_type): try: @@ -323,3 +380,7 @@ def to_representation(self, value): base = super(SerializerMethodResourceRelatedField, self) return [base.to_representation(x) for x in value] return super(SerializerMethodResourceRelatedField, self).to_representation(value) + + +class SerializerMethodHyperLinkedRelatedField(SkipDataMixin, SerializerMethodResourceRelatedField): + pass diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index ba6424ee..88a73b87 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -2,7 +2,7 @@ Renderers """ import copy -from collections import OrderedDict, defaultdict +from collections import Iterable, OrderedDict, defaultdict import inflection from django.db.models import Manager @@ -13,7 +13,7 @@ import rest_framework_json_api from rest_framework_json_api import utils -from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.relations import HyperLinkedMixin, ResourceRelatedField, SkipDataMixin class JSONRenderer(renderers.JSONRenderer): @@ -126,7 +126,13 @@ def extract_relationships(cls, fields, resource, resource_instance): }}) continue - if isinstance(field, ResourceRelatedField): + relation_data = {} + if isinstance(field, HyperLinkedMixin): + field_links = field.get_links(resource_instance, field.related_link_lookup_field) + relation_data.update({'links': field_links} if field_links else dict()) + data.update({field_name: relation_data}) + + if isinstance(field, (ResourceRelatedField, )): relation_instance_id = getattr(resource_instance, source + "_id", None) if not relation_instance_id: resolved, relation_instance = utils.get_relation_instance(resource_instance, @@ -134,17 +140,9 @@ def extract_relationships(cls, fields, resource, resource_instance): if not resolved: continue - # special case for ResourceRelatedField - relation_data = { - 'data': resource.get(field_name) - } + if not isinstance(field, SkipDataMixin): + relation_data.update({'data': resource.get(field_name)}) - field_links = field.get_links( - resource_instance, field.related_link_lookup_field) - relation_data.update( - {'links': field_links} - if field_links else dict() - ) data.update({field_name: relation_data}) continue @@ -186,12 +184,22 @@ def extract_relationships(cls, fields, resource, resource_instance): if not resolved: continue + relation_data = {} + + if isinstance(resource.get(field_name), Iterable): + relation_data.update( + { + 'meta': {'count': len(resource.get(field_name))} + } + ) + if isinstance(field.child_relation, ResourceRelatedField): # special case for ResourceRelatedField - relation_data = { - 'data': resource.get(field_name) - } + relation_data.update( + {'data': resource.get(field_name)} + ) + if isinstance(field.child_relation, HyperLinkedMixin): field_links = field.child_relation.get_links( resource_instance, field.child_relation.related_link_lookup_field @@ -200,13 +208,7 @@ def extract_relationships(cls, fields, resource, resource_instance): {'links': field_links} if field_links else dict() ) - relation_data.update( - { - 'meta': { - 'count': len(resource.get(field_name)) - } - } - ) + data.update({field_name: relation_data}) continue From af5e22226d600d36de1964966d1ccd3eccf27063 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Wed, 18 Jul 2018 16:30:34 +0300 Subject: [PATCH 02/11] Added HyperLinked fields to example --- example/serializers.py | 46 +++++++++++++++++++++++++++++++++++++++--- example/urls.py | 13 ++++++++++++ example/views.py | 22 ++++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index da491e7f..8bfd6204 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -67,19 +67,58 @@ def __init__(self, *args, **kwargs): } body_format = serializers.SerializerMethodField() + # single related from model + blog_hyperlinked = relations.HyperLinkedRelatedField( + related_link_view_name='entry-blog', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + read_only=True, + source='blog' + ) # many related from model comments = relations.ResourceRelatedField( many=True, read_only=True) + # many related hyperlinked from model + comments_hyperlinked = relations.HyperLinkedRelatedField( + related_link_view_name='entry-comments', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + many=True, + read_only=True, + source='comments' + ) # many related from serializer suggested = relations.SerializerMethodResourceRelatedField( - source='get_suggested', model=Entry, many=True, read_only=True, related_link_view_name='entry-suggested', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', + source='get_suggested', + model=Entry, + many=True, + read_only=True + ) + # many related hyperlinked from serializer + suggested_hyperlinked = relations.SerializerMethodHyperLinkedRelatedField( + related_link_view_name='entry-suggested', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + source='get_suggested', + model=Entry, + many=True, + read_only=True ) # single related from serializer featured = relations.SerializerMethodResourceRelatedField( source='get_featured', model=Entry, read_only=True) + # single related hyperlinked from serializer + featured_hyperlinked = relations.SerializerMethodHyperLinkedRelatedField( + related_link_view_name='entry-featured', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + source='get_featured', + model=Entry, + read_only=True + ) tags = TaggedItemSerializer(many=True, read_only=True) def get_suggested(self, obj): @@ -93,8 +132,9 @@ def get_body_format(self, obj): class Meta: model = Entry - fields = ('blog', 'headline', 'body_text', 'pub_date', 'mod_date', - 'authors', 'comments', 'featured', 'suggested', 'tags') + fields = ('blog', 'blog_hyperlinked', 'headline', 'body_text', 'pub_date', 'mod_date', + 'authors', 'comments', 'comments_hyperlinked', 'featured', 'suggested', + 'suggested_hyperlinked', 'tags', 'featured_hyperlinked') read_only_fields = ('tags',) meta_fields = ('body_format',) diff --git a/example/urls.py b/example/urls.py index 688ce70e..469ce53a 100644 --- a/example/urls.py +++ b/example/urls.py @@ -32,6 +32,19 @@ EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), + url(r'entries/(?P[^/.]+)/blog', + BlogViewSet.as_view({'get': 'retrieve'}), + name='entry-blog'), + url(r'entries/(?P[^/.]+)/comments', + CommentViewSet.as_view({'get': 'list'}), + name='entry-comments'), + url(r'entries/(?P[^/.]+)/authors', + AuthorViewSet.as_view({'get': 'list'}), + name='entry-authors'), + url(r'entries/(?P[^/.]+)/featured', + EntryViewSet.as_view({'get': 'retrieve'}), + name='entry-featured'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), diff --git a/example/views.py b/example/views.py index 6182c6df..b45cd52b 100644 --- a/example/views.py +++ b/example/views.py @@ -26,6 +26,13 @@ class BlogViewSet(ModelViewSet): queryset = Blog.objects.all() serializer_class = BlogSerializer + def get_object(self): + entry_pk = self.kwargs.get('entry_pk', None) + if entry_pk is not None: + return Entry.objects.get(id=entry_pk).blog + + return super(BlogViewSet, self).get_object() + class JsonApiViewSet(ModelViewSet): """ @@ -68,6 +75,14 @@ class EntryViewSet(ModelViewSet): def get_serializer_class(self): return EntrySerializer + def get_object(self): + # Handle featured + entry_pk = self.kwargs.get('entry_pk', None) + if entry_pk is not None: + return Entry.objects.first() + + return super(EntryViewSet, self).get_object() + class NoPagination(PageNumberPagination): page_size = None @@ -90,6 +105,13 @@ class CommentViewSet(ModelViewSet): 'author': ['author__bio', 'author__entries'], } + def get_queryset(self, *args, **kwargs): + entry_pk = self.kwargs.get('entry_pk', None) + if entry_pk is not None: + return self.queryset.filter(entry_id=entry_pk) + + return super(CommentViewSet, self).get_queryset() + class CompanyViewset(ModelViewSet): queryset = Company.objects.all() From 659ea97f34d1874176d4dc2ba051c0976ee3b572 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Wed, 18 Jul 2018 16:32:03 +0300 Subject: [PATCH 03/11] Added tests --- example/tests/test_relations.py | 150 +++++++++++++++++++++++++++++++- example/urls_test.py | 15 ++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index e7d27b76..62109aa4 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -1,15 +1,23 @@ from __future__ import absolute_import +from django.test.client import RequestFactory from django.utils import timezone from rest_framework import serializers +from rest_framework.fields import SkipField +from rest_framework.reverse import reverse from rest_framework_json_api.exceptions import Conflict -from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.relations import ( + HyperLinkedRelatedField, + ResourceRelatedField, + SerializerMethodHyperLinkedRelatedField +) from rest_framework_json_api.utils import format_resource_type from . import TestBase from example.models import Author, Blog, Comment, Entry from example.serializers import CommentSerializer +from example.views import EntryViewSet class TestResourceRelatedField(TestBase): @@ -129,6 +137,117 @@ def test_invalid_resource_id_object(self): } +class TestHyperLinkedFieldBase(TestBase): + + def setUp(self): + super(TestHyperLinkedFieldBase, self).setUp() + self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.entry = Entry.objects.create( + blog=self.blog, + headline='headline', + body_text='body_text', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=3 + ) + self.comment = Comment.objects.create( + entry=self.entry, + body='testing one two three', + ) + + self.request = RequestFactory().get(reverse('entry-detail', kwargs={'pk': self.entry.pk})) + self.view = EntryViewSet(request=self.request, kwargs={'entry_pk': self.entry.id}) + + +class TestHyperLinkedRelatedField(TestHyperLinkedFieldBase): + + def test_single_hyperlinked_related_field(self): + field = HyperLinkedRelatedField( + related_link_view_name='entry-blog', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + read_only=True, + ) + field._context = {'request': self.request, 'view': self.view} + field.field_name = 'blog' + + self.assertRaises(NotImplementedError, field.to_representation, self.entry) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + links_expected = { + 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + } + got = field.get_links(self.entry) + self.assertEqual(got, links_expected) + + def test_many_hyperlinked_related_field(self): + field = HyperLinkedRelatedField( + related_link_view_name='entry-comments', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + read_only=True, + many=True + ) + field._context = {'request': self.request, 'view': self.view} + field.field_name = 'comments' + + self.assertRaises(NotImplementedError, field.to_representation, self.entry.comments.all()) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + links_expected = { + 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + } + got = field.child_relation.get_links(self.entry) + self.assertEqual(got, links_expected) + + +class TestSerializerMethodHyperLinkedRelatedField(TestHyperLinkedFieldBase): + + def test_single_serializer_method_hyperlinked_related_field(self): + serializer = EntryModelSerializerWithHyperLinks( + instance=self.entry, + context={ + 'request': self.request, + 'view': self.view + } + ) + field = serializer.fields['blog'] + + self.assertRaises(NotImplementedError, field.to_representation, self.entry) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + expected = { + 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + } + got = field.get_links(self.entry) + self.assertEqual(got, expected) + + def test_many_serializer_method_hyperlinked_related_field(self): + serializer = EntryModelSerializerWithHyperLinks( + instance=self.entry, + context={ + 'request': self.request, + 'view': self.view + } + ) + field = serializer.fields['comments'] + + self.assertRaises(NotImplementedError, field.to_representation, self.entry) + self.assertRaises(SkipField, field.get_attribute, self.entry) + + expected = { + 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), + 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + } + got = field.get_links(self.entry) + self.assertEqual(got, expected) + + class BlogResourceRelatedField(ResourceRelatedField): def get_queryset(self): return Blog.objects @@ -149,3 +268,32 @@ class EntryModelSerializer(serializers.ModelSerializer): class Meta: model = Entry fields = ('authors', 'comments') + + +class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): + blog = SerializerMethodHyperLinkedRelatedField( + related_link_view_name='entry-blog', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + many=True, + read_only=True, + source='get_blog' + ) + comments = SerializerMethodHyperLinkedRelatedField( + related_link_view_name='entry-comments', + related_link_url_kwarg='entry_pk', + self_link_view_name='entry-relationships', + many=True, + read_only=True, + source='get_comments' + ) + + class Meta: + model = Entry + fields = ('blog', 'comments',) + + def get_blog(self, obj): + return obj.blog + + def get_authors(self, obj): + return obj.comments.all() diff --git a/example/urls_test.py b/example/urls_test.py index 486ce418..3ec07380 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -37,10 +37,25 @@ GenericIdentity.as_view(), name='user-default'), + url(r'^entries/(?P[^/.]+)/blog', + BlogViewSet.as_view({'get': 'retrieve'}), + name='entry-blog' + ), + url(r'^entries/(?P[^/.]+)/comments', + CommentViewSet.as_view({'get': 'list'}), + name='entry-comments' + ), url(r'^entries/(?P[^/.]+)/suggested/', EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), + url(r'entries/(?P[^/.]+)/authors', + AuthorViewSet.as_view({'get': 'list'}), + name='entry-authors'), + url(r'entries/(?P[^/.]+)/featured', + EntryViewSet.as_view({'get': 'retrieve'}), + name='entry-featured'), + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', EntryRelationshipView.as_view(), name='entry-relationships'), From 42a94489259716ecbe43def0698a0a1ed2e8ae95 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Wed, 18 Jul 2018 17:16:47 +0300 Subject: [PATCH 04/11] Fixed failing tests --- .../test_non_paginated_responses.py | 50 +++++++++++++++++++ example/tests/integration/test_pagination.py | 25 ++++++++++ 2 files changed, 75 insertions(+) diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 5769f6da..dab32d94 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -35,6 +35,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blog": { "data": {"type": "blogs", "id": "1"} }, + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/1/blog", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked" + } + }, "authors": { "meta": {"count": 1}, "data": [{"type": "authors", "id": "1"}] @@ -43,6 +49,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "meta": {"count": 1}, "data": [{"type": "comments", "id": "1"}] }, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/1/comments", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + } + }, "suggested": { "data": [{"type": "entries", "id": "2"}], "links": { @@ -50,6 +62,19 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "self": "http://testserver/entries/1/relationships/suggested" } }, + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/1/suggested/", + "self": "http://testserver/entries/1" + "/relationships/suggested_hyperlinked" + } + }, + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/1/featured", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + } + }, "tags": { "data": [] } @@ -73,6 +98,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blog": { "data": {"type": "blogs", "id": "2"} }, + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/2/blog", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked", + } + }, "authors": { "meta": {"count": 1}, "data": [{"type": "authors", "id": "2"}] @@ -81,6 +112,12 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "meta": {"count": 1}, "data": [{"type": "comments", "id": "2"}] }, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/2/comments", + "self": "http://testserver/entries/2/relationships/comments_hyperlinked" + } + }, "suggested": { "data": [{"type": "entries", "id": "1"}], "links": { @@ -88,6 +125,19 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "self": "http://testserver/entries/2/relationships/suggested" } }, + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/2/suggested/", + "self": "http://testserver/entries/2" + "/relationships/suggested_hyperlinked" + } + }, + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/2/featured", + "self": "http://testserver/entries/2/relationships/featured_hyperlinked" + } + }, "tags": { "data": [] } diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index cff9d9af..18306e3e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -36,6 +36,12 @@ def test_pagination_with_single_entry(single_entry, client): "blog": { "data": {"type": "blogs", "id": "1"} }, + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/1/blog", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + } + }, "authors": { "meta": {"count": 1}, "data": [{"type": "authors", "id": "1"}] @@ -44,6 +50,12 @@ def test_pagination_with_single_entry(single_entry, client): "meta": {"count": 1}, "data": [{"type": "comments", "id": "1"}] }, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/1/comments", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + } + }, "suggested": { "data": [], "links": { @@ -51,6 +63,19 @@ def test_pagination_with_single_entry(single_entry, client): "self": "http://testserver/entries/1/relationships/suggested" } }, + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/1/suggested/", + "self": "http://testserver/entries/1" + "/relationships/suggested_hyperlinked" + } + }, + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/1/featured", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + } + }, "tags": { "data": [ { From ca657298f6402d2ae04d4c6fdc289ec661718905 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Wed, 18 Jul 2018 18:26:35 +0300 Subject: [PATCH 05/11] Added docs --- docs/usage.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 714137f4..05afcf0f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -324,6 +324,8 @@ When set to pluralize: ### Related fields +#### ResourceRelatedField + Because of the additional structure needed to represent relationships in JSON API, this package provides the `ResourceRelatedField` for serializers, which works similarly to `PrimaryKeyRelatedField`. By default, @@ -435,6 +437,12 @@ class LineItemViewSet(viewsets.ModelViewSet): return queryset ``` +#### HyperlinkedRelatedField + +In order to improve performance by saving some sql queries we can skip `data` +key. Use `HyperlinkedRelatedField`. It works same as `ResourceRelatedField` +but just skips `data` calculating. + ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the From 80b819a9d9bf3e51564d15e7fe170d1585661c4a Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Thu, 19 Jul 2018 13:29:44 +0300 Subject: [PATCH 06/11] Added few words to docs --- docs/usage.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 05afcf0f..d1dc0f07 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -440,7 +440,8 @@ class LineItemViewSet(viewsets.ModelViewSet): #### HyperlinkedRelatedField In order to improve performance by saving some sql queries we can skip `data` -key. Use `HyperlinkedRelatedField`. It works same as `ResourceRelatedField` +key, and thus decrease payload size. Use `HyperlinkedRelatedField`. It works same as `ResourceRelatedField` +>>>>>>> 8595bfe... Added few words to docs but just skips `data` calculating. ### RelationshipView From 69e1a2282753e71ad266bbcf028ad0d669fab0a0 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Thu, 19 Jul 2018 13:33:06 +0300 Subject: [PATCH 07/11] Renamed HyperLinked to Hyperlinked --- example/serializers.py | 8 ++++---- example/tests/test_relations.py | 20 ++++++++++---------- rest_framework_json_api/relations.py | 10 +++++----- rest_framework_json_api/renderers.py | 6 +++--- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index 8bfd6204..f43accac 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -68,7 +68,7 @@ def __init__(self, *args, **kwargs): body_format = serializers.SerializerMethodField() # single related from model - blog_hyperlinked = relations.HyperLinkedRelatedField( + blog_hyperlinked = relations.HyperlinkedRelatedField( related_link_view_name='entry-blog', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', @@ -79,7 +79,7 @@ def __init__(self, *args, **kwargs): comments = relations.ResourceRelatedField( many=True, read_only=True) # many related hyperlinked from model - comments_hyperlinked = relations.HyperLinkedRelatedField( + comments_hyperlinked = relations.HyperlinkedRelatedField( related_link_view_name='entry-comments', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', @@ -98,7 +98,7 @@ def __init__(self, *args, **kwargs): read_only=True ) # many related hyperlinked from serializer - suggested_hyperlinked = relations.SerializerMethodHyperLinkedRelatedField( + suggested_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( related_link_view_name='entry-suggested', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', @@ -111,7 +111,7 @@ def __init__(self, *args, **kwargs): featured = relations.SerializerMethodResourceRelatedField( source='get_featured', model=Entry, read_only=True) # single related hyperlinked from serializer - featured_hyperlinked = relations.SerializerMethodHyperLinkedRelatedField( + featured_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( related_link_view_name='entry-featured', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index 62109aa4..ba88c1be 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -8,9 +8,9 @@ from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import ( - HyperLinkedRelatedField, + HyperlinkedRelatedField, ResourceRelatedField, - SerializerMethodHyperLinkedRelatedField + SerializerMethodHyperlinkedRelatedField ) from rest_framework_json_api.utils import format_resource_type @@ -137,10 +137,10 @@ def test_invalid_resource_id_object(self): } -class TestHyperLinkedFieldBase(TestBase): +class TestHyperlinkedFieldBase(TestBase): def setUp(self): - super(TestHyperLinkedFieldBase, self).setUp() + super(TestHyperlinkedFieldBase, self).setUp() self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, @@ -161,10 +161,10 @@ def setUp(self): self.view = EntryViewSet(request=self.request, kwargs={'entry_pk': self.entry.id}) -class TestHyperLinkedRelatedField(TestHyperLinkedFieldBase): +class TestHyperlinkedRelatedField(TestHyperlinkedFieldBase): def test_single_hyperlinked_related_field(self): - field = HyperLinkedRelatedField( + field = HyperlinkedRelatedField( related_link_view_name='entry-blog', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', @@ -184,7 +184,7 @@ def test_single_hyperlinked_related_field(self): self.assertEqual(got, links_expected) def test_many_hyperlinked_related_field(self): - field = HyperLinkedRelatedField( + field = HyperlinkedRelatedField( related_link_view_name='entry-comments', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', @@ -205,7 +205,7 @@ def test_many_hyperlinked_related_field(self): self.assertEqual(got, links_expected) -class TestSerializerMethodHyperLinkedRelatedField(TestHyperLinkedFieldBase): +class TestSerializerMethodHyperlinkedRelatedField(TestHyperlinkedFieldBase): def test_single_serializer_method_hyperlinked_related_field(self): serializer = EntryModelSerializerWithHyperLinks( @@ -271,7 +271,7 @@ class Meta: class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): - blog = SerializerMethodHyperLinkedRelatedField( + blog = SerializerMethodHyperlinkedRelatedField( related_link_view_name='entry-blog', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', @@ -279,7 +279,7 @@ class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): read_only=True, source='get_blog' ) - comments = SerializerMethodHyperLinkedRelatedField( + comments = SerializerMethodHyperlinkedRelatedField( related_link_view_name='entry-comments', related_link_url_kwarg='entry_pk', self_link_view_name='entry-relationships', diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index df14c536..1c51d2da 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -51,7 +51,7 @@ class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): pass -class HyperLinkedMixin(object): +class HyperlinkedMixin(object): self_link_view_name = None related_link_view_name = None related_link_lookup_field = 'pk' @@ -74,7 +74,7 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar # implicit `self` argument to be passed. self.reverse = reverse - super(HyperLinkedMixin, self).__init__(**kwargs) + super(HyperlinkedMixin, self).__init__(**kwargs) def get_url(self, name, view_name, kwargs, request): """ @@ -126,7 +126,7 @@ def get_links(self, obj=None, lookup_field='pk'): return return_data -class HyperLinkedRelatedField(HyperLinkedMixin, SkipDataMixin, RelatedField): +class HyperlinkedRelatedField(HyperlinkedMixin, SkipDataMixin, RelatedField): @classmethod def many_init(cls, *args, **kwargs): @@ -152,7 +152,7 @@ def many_init(cls, *args, **kwargs): return ManyRelatedFieldWithNoData(**list_kwargs) -class ResourceRelatedField(HyperLinkedMixin, PrimaryKeyRelatedField): +class ResourceRelatedField(HyperlinkedMixin, PrimaryKeyRelatedField): _skip_polymorphic_optimization = True self_link_view_name = None related_link_view_name = None @@ -382,5 +382,5 @@ def to_representation(self, value): return super(SerializerMethodResourceRelatedField, self).to_representation(value) -class SerializerMethodHyperLinkedRelatedField(SkipDataMixin, SerializerMethodResourceRelatedField): +class SerializerMethodHyperlinkedRelatedField(SkipDataMixin, SerializerMethodResourceRelatedField): pass diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 88a73b87..60836e97 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -13,7 +13,7 @@ import rest_framework_json_api from rest_framework_json_api import utils -from rest_framework_json_api.relations import HyperLinkedMixin, ResourceRelatedField, SkipDataMixin +from rest_framework_json_api.relations import HyperlinkedMixin, ResourceRelatedField, SkipDataMixin class JSONRenderer(renderers.JSONRenderer): @@ -127,7 +127,7 @@ def extract_relationships(cls, fields, resource, resource_instance): continue relation_data = {} - if isinstance(field, HyperLinkedMixin): + if isinstance(field, HyperlinkedMixin): field_links = field.get_links(resource_instance, field.related_link_lookup_field) relation_data.update({'links': field_links} if field_links else dict()) data.update({field_name: relation_data}) @@ -199,7 +199,7 @@ def extract_relationships(cls, fields, resource, resource_instance): {'data': resource.get(field_name)} ) - if isinstance(field.child_relation, HyperLinkedMixin): + if isinstance(field.child_relation, HyperlinkedMixin): field_links = field.child_relation.get_links( resource_instance, field.child_relation.related_link_lookup_field From 4fc3047fec565afab58aee18f44b1973adb9fe7e Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Thu, 19 Jul 2018 13:41:58 +0300 Subject: [PATCH 08/11] Renamed get_authors to get_comments, added tests --- example/tests/test_relations.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index ba88c1be..94db188a 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -247,6 +247,20 @@ def test_many_serializer_method_hyperlinked_related_field(self): got = field.get_links(self.entry) self.assertEqual(got, expected) + def test_get_blog(self): + serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) + got = serializer.get_blog(self.entry) + expected = self.entry.blog + + self.assertEqual(got, expected) + + def test_get_comments(self): + serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) + got = serializer.get_comments(self.entry) + expected = self.entry.comments.all() + + self.assertListEqual(list(got), list(expected)) + class BlogResourceRelatedField(ResourceRelatedField): def get_queryset(self): @@ -295,5 +309,5 @@ class Meta: def get_blog(self, obj): return obj.blog - def get_authors(self, obj): + def get_comments(self, obj): return obj.comments.all() From 802b0b64543ac7c8cbfa4a3141341fc1cb4516de Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Thu, 19 Jul 2018 14:18:24 +0300 Subject: [PATCH 09/11] Added more tests --- example/tests/test_views.py | 124 ++++++++++++++++++++++++++++++++++++ example/views.py | 2 +- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index db3a3407..9cccf2f9 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -280,3 +280,127 @@ def test_no_content_response(self): response = self.client.delete(url) assert response.status_code == 204, response.rendered_content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() + + +class TestBlogViewSet(APITestCase): + + def setUp(self): + self.blog = Blog.objects.create( + name='Some Blog', + tagline="It's a blog" + ) + self.entry = Entry.objects.create( + blog=self.blog, + headline='headline one', + body_text='body_text two', + ) + + def test_get_object_gives_correct_blog(self): + url = reverse('entry-blog', kwargs={'entry_pk': self.entry.id}) + resp = self.client.get(url) + expected = { + 'data': { + 'attributes': {'name': self.blog.name}, + 'id': '{}'.format(self.blog.id), + 'links': {'self': 'http://testserver/blogs/{}'.format(self.blog.id)}, + 'meta': {'copyright': 2018}, + 'relationships': {'tags': {'data': []}}, + 'type': 'blogs' + }, + 'meta': {'apiDocs': '/docs/api/blogs'} + } + got = resp.json() + self.assertEqual(got, expected) + + +class TestEntryViewSet(APITestCase): + + def setUp(self): + self.blog = Blog.objects.create( + name='Some Blog', + tagline="It's a blog" + ) + self.first_entry = Entry.objects.create( + blog=self.blog, + headline='headline two', + body_text='body_text two', + ) + self.second_entry = Entry.objects.create( + blog=self.blog, + headline='headline two', + body_text='body_text two', + ) + self.maxDiff = None + + def test_get_object_gives_correct_entry(self): + url = reverse('entry-featured', kwargs={'entry_pk': self.first_entry.id}) + resp = self.client.get(url) + expected = { + 'data': { + 'attributes': { + 'bodyText': self.second_entry.body_text, + 'headline': self.second_entry.headline, + 'modDate': self.second_entry.mod_date, + 'pubDate': self.second_entry.pub_date + }, + 'id': '{}'.format(self.second_entry.id), + 'meta': {'bodyFormat': 'text'}, + 'relationships': { + 'authors': {'data': [], 'meta': {'count': 0}}, + 'blog': { + 'data': { + 'id': '{}'.format(self.second_entry.blog_id), + 'type': 'blogs' + } + }, + 'blogHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/blog'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}' + '/relationships/blog_hyperlinked'.format(self.second_entry.id) + } + }, + 'comments': { + 'data': [], + 'meta': {'count': 0} + }, + 'commentsHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/comments'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}/relationships' + '/comments_hyperlinked'.format(self.second_entry.id) + } + }, + 'featuredHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/featured'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}/relationships' + '/featured_hyperlinked'.format(self.second_entry.id) + } + }, + 'suggested': { + 'data': [{'id': '1', 'type': 'entries'}], + 'links': { + 'related': 'http://testserver/entries/{}' + '/suggested/'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}' + '/relationships/suggested'.format(self.second_entry.id) + } + }, + 'suggestedHyperlinked': { + 'links': { + 'related': 'http://testserver/entries/{}' + '/suggested/'.format(self.second_entry.id), + 'self': 'http://testserver/entries/{}/relationships' + '/suggested_hyperlinked'.format(self.second_entry.id) + } + }, + 'tags': {'data': []}}, + 'type': 'posts' + } + } + got = resp.json() + self.assertEqual(got, expected) diff --git a/example/views.py b/example/views.py index b45cd52b..5dfc3341 100644 --- a/example/views.py +++ b/example/views.py @@ -79,7 +79,7 @@ def get_object(self): # Handle featured entry_pk = self.kwargs.get('entry_pk', None) if entry_pk is not None: - return Entry.objects.first() + return Entry.objects.exclude(pk=entry_pk).first() return super(EntryViewSet, self).get_object() From 3e5f0a86e3b5654d93bc25e799351e0b52802e06 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Thu, 19 Jul 2018 14:24:48 +0300 Subject: [PATCH 10/11] Updated change log, added myself to authors --- AUTHORS | 1 + CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index b34f17c6..103d327b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell +Anton Shutik Christian Zosel Greg Aker Jamie Bliss diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c4512b..7bad2f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](http://www.django-rest-framework.org/api-guide/testing/#configuration) * Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) +* Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) v2.5.0 - Released July 11, 2018 From 5cb5c82ed8c92d09ce09452b2b7079146c1d9981 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 19 Jul 2018 14:49:21 +0200 Subject: [PATCH 11/11] Clarify docs of HyperlinkedResourceRelatedField --- docs/usage.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index d1dc0f07..57479c96 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -439,10 +439,9 @@ class LineItemViewSet(viewsets.ModelViewSet): #### HyperlinkedRelatedField -In order to improve performance by saving some sql queries we can skip `data` -key, and thus decrease payload size. Use `HyperlinkedRelatedField`. It works same as `ResourceRelatedField` ->>>>>>> 8595bfe... Added few words to docs -but just skips `data` calculating. +`HyperlinkedRelatedField` has same functionality as `ResourceRelatedField` but does +not render `data`. Use this in case you only need links of relationships and want to lower payload +and increase performance. ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build