From 314ea5ae9c38ed6d89a76daa4ebd1bc6a4753d31 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Wed, 25 Mar 2020 17:35:54 +0300 Subject: [PATCH 01/18] support nested structures --- example/serializers.py | 17 ++ example/tests/test_rendering_strategies.py | 80 +++++++++ example/urls_test.py | 4 +- example/views.py | 9 +- rest_framework_json_api/renderers.py | 196 +++++++++++---------- rest_framework_json_api/serializers.py | 4 +- rest_framework_json_api/settings.py | 20 +++ 7 files changed, 234 insertions(+), 96 deletions(-) create mode 100644 example/tests/test_rendering_strategies.py diff --git a/example/serializers.py b/example/serializers.py index 9ed60e90..d9eac355 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -257,6 +257,23 @@ def get_first_entry(self, obj): return obj.entries.first() +class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): + entry = EntryDRFSerializers() + + class Meta: + model = Comment + exclude = ('created_at', 'modified_at', 'author') + # fields = ('entry', 'body', 'author',) + + +class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): + comments = CommentWithNestedFieldsSerializer(many=True) + + class Meta: + model = Author + fields = ('name', 'email', 'comments') + + class WriterSerializer(serializers.ModelSerializer): included_serializers = { 'bio': AuthorBioSerializer diff --git a/example/tests/test_rendering_strategies.py b/example/tests/test_rendering_strategies.py new file mode 100644 index 00000000..add08b84 --- /dev/null +++ b/example/tests/test_rendering_strategies.py @@ -0,0 +1,80 @@ +from __future__ import absolute_import + +import json + +from django.utils import timezone +from rest_framework.reverse import reverse + +from . import TestBase +from example.models import Author, Blog, Comment, Entry +from django.test import override_settings + + +class TestResourceRelatedField(TestBase): + list_url = reverse('authors-nested-list') + + def setUp(self): + super(TestResourceRelatedField, 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 + ) + for i in range(1, 6): + name = 'some_author{}'.format(i) + self.entry.authors.add( + Author.objects.create(name=name, email='{}@example.org'.format(name)) + ) + + self.comment = Comment.objects.create( + entry=self.entry, + body='testing one two three', + author=Author.objects.first() + ) + + def test_attribute_rendering_strategy(self): + with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY='ATTRIBUTE'): + response = self.client.get(self.list_url) + + expected = { + "links": { + "first": "http://testserver/authors-nested?page%5Bnumber%5D=1", + "last": "http://testserver/authors-nested?page%5Bnumber%5D=5", + "next": "http://testserver/authors-nested?page%5Bnumber%5D=2", + "prev": None + }, + "data": [ + { + "type": "authors", + "id": "1", + "attributes": { + "name": "some_author1", + "email": "some_author1@example.org", + "comments": [ + { + "id": 1, + "entry": { + "tags": [], + "url": "http://testserver/drf-blogs/1" + }, + "body": "testing one two three" + } + ] + } + } + ], + "meta": { + "pagination": { + "page": 1, + "pages": 5, + "count": 5 + } + } + } + assert expected == response.json() diff --git a/example/urls_test.py b/example/urls_test.py index 020ab2f3..dc9f9558 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -18,7 +18,8 @@ NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, + AuthorWithNestedFieldsViewSet ) router = routers.DefaultRouter(trailing_slash=False) @@ -32,6 +33,7 @@ router.register(r'filterset-entries', FiltersetEntryViewSet, 'filterset-entry') router.register(r'nofilterset-entries', NoFiltersetEntryViewSet, 'nofilterset-entry') router.register(r'authors', AuthorViewSet) +router.register(r'authors-nested', AuthorWithNestedFieldsViewSet, 'authors-nested') router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) router.register(r'projects', ProjectViewset) diff --git a/example/views.py b/example/views.py index 33393be9..4b881db4 100644 --- a/example/views.py +++ b/example/views.py @@ -23,8 +23,8 @@ EntryDRFSerializers, EntrySerializer, ProjectSerializer, - ProjectTypeSerializer -) + ProjectTypeSerializer, + AuthorWithNestedFieldsSerializer) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -175,6 +175,11 @@ class AuthorViewSet(ModelViewSet): serializer_class = AuthorSerializer +class AuthorWithNestedFieldsViewSet(ModelViewSet): + queryset = Author.objects.all() + serializer_class = AuthorWithNestedFieldsSerializer + + class CommentViewSet(ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index ced826b0..786ab733 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -13,6 +13,7 @@ from rest_framework.relations import PKOnlyObject from rest_framework.serializers import BaseSerializer, ListSerializer, Serializer from rest_framework.settings import api_settings +from .settings import json_api_settings, RELATIONS_RENDERING_STRATEGY, ATTRIBUTE_RENDERING_STRATEGY import rest_framework_json_api from rest_framework_json_api import utils @@ -52,6 +53,7 @@ def extract_attributes(cls, fields, resource): Builds the `attributes` object of the JSON API resource object. """ data = OrderedDict() + nested_serializers_rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON API so remove it from attributes if field_name == 'id': @@ -61,10 +63,16 @@ def extract_attributes(cls, fields, resource): continue # Skip fields with relations if isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) + field, (relations.RelatedField, relations.ManyRelatedField) ): continue + if isinstance(field, BaseSerializer): + if nested_serializers_rendering_strategy == RELATIONS_RENDERING_STRATEGY: + continue + elif nested_serializers_rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: + pass + # Skip read_only attribute fields when `resource` is an empty # serializer. Prevents the "Raw Data" form of the browsable API # from rendering `"foo": null` for read only fields @@ -89,6 +97,7 @@ def extract_relationships(cls, fields, resource, resource_instance): from rest_framework_json_api.relations import ResourceRelatedField data = OrderedDict() + nested_serializers_rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -243,55 +252,56 @@ def extract_relationships(cls, fields, resource, resource_instance): }) continue - if isinstance(field, ListSerializer): - resolved, relation_instance = utils.get_relation_instance( - resource_instance, source, field.parent - ) - if not resolved: - continue - - relation_data = list() - - serializer_data = resource.get(field_name) - resource_instance_queryset = list(relation_instance) - if isinstance(serializer_data, list): - for position in range(len(serializer_data)): - nested_resource_instance = resource_instance_queryset[position] - nested_resource_instance_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) - ) - - relation_data.append(OrderedDict([ - ('type', nested_resource_instance_type), - ('id', encoding.force_text(nested_resource_instance.pk)) - ])) - - data.update({field_name: {'data': relation_data}}) - continue - - if isinstance(field, Serializer): - relation_instance_id = getattr(resource_instance, source + "_id", None) - if not relation_instance_id: + if nested_serializers_rendering_strategy == RELATIONS_RENDERING_STRATEGY: + if isinstance(field, ListSerializer): resolved, relation_instance = utils.get_relation_instance( resource_instance, source, field.parent ) if not resolved: continue - if relation_instance is not None: - relation_instance_id = relation_instance.pk + relation_data = list() - data.update({ - field_name: { - 'data': ( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_text(relation_instance_id)) - ]) if resource.get(field_name) else None) - } - }) - continue + serializer_data = resource.get(field_name) + resource_instance_queryset = list(relation_instance) + if isinstance(serializer_data, list): + for position in range(len(serializer_data)): + nested_resource_instance = resource_instance_queryset[position] + nested_resource_instance_type = ( + relation_type or + utils.get_resource_type_from_instance(nested_resource_instance) + ) + + relation_data.append(OrderedDict([ + ('type', nested_resource_instance_type), + ('id', encoding.force_text(nested_resource_instance.pk)) + ])) + + data.update({field_name: {'data': relation_data}}) + continue + + if isinstance(field, Serializer): + relation_instance_id = getattr(resource_instance, source + "_id", None) + if not relation_instance_id: + resolved, relation_instance = utils.get_relation_instance( + resource_instance, source, field.parent + ) + if not resolved: + continue + + if relation_instance is not None: + relation_instance_id = relation_instance.pk + + data.update({ + field_name: { + 'data': ( + OrderedDict([ + ('type', relation_type), + ('id', encoding.force_text(relation_instance_id)) + ]) if resource.get(field_name) else None) + } + }) + continue return utils.format_field_names(data) @@ -327,6 +337,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource included_serializers = utils.get_included_serializers(current_serializer) included_resources = copy.copy(included_resources) included_resources = [inflection.underscore(value) for value in included_resources] + nested_serializers_rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY for field_name, field in iter(fields.items()): # Skip URL field @@ -381,65 +392,66 @@ def extract_included(cls, fields, resource, resource_instance, included_resource for key in included_resources if field_name == key.split('.')[0]] - if isinstance(field, ListSerializer): - serializer = field.child - relation_type = utils.get_resource_type_from_serializer(serializer) - relation_queryset = list(relation_instance) - - if serializer_data: - for position in range(len(serializer_data)): - serializer_resource = serializer_data[position] - nested_resource_instance = relation_queryset[position] - resource_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) - ) - serializer_fields = utils.get_serializer_fields( - serializer.__class__( - nested_resource_instance, context=serializer.context + if nested_serializers_rendering_strategy == RELATIONS_RENDERING_STRATEGY: + if isinstance(field, ListSerializer): + serializer = field.child + relation_type = utils.get_resource_type_from_serializer(serializer) + relation_queryset = list(relation_instance) + + if serializer_data: + for position in range(len(serializer_data)): + serializer_resource = serializer_data[position] + nested_resource_instance = relation_queryset[position] + resource_type = ( + relation_type or + utils.get_resource_type_from_instance(nested_resource_instance) ) - ) + serializer_fields = utils.get_serializer_fields( + serializer.__class__( + nested_resource_instance, context=serializer.context + ) + ) + new_item = cls.build_json_resource_obj( + serializer_fields, + serializer_resource, + nested_resource_instance, + resource_type, + getattr(serializer, '_poly_force_type_resolution', False) + ) + included_cache[new_item['type']][new_item['id']] = \ + utils.format_field_names(new_item) + cls.extract_included( + serializer_fields, + serializer_resource, + nested_resource_instance, + new_included_resources, + included_cache, + ) + + if isinstance(field, Serializer): + relation_type = utils.get_resource_type_from_serializer(field) + + # Get the serializer fields + serializer_fields = utils.get_serializer_fields(field) + if serializer_data: new_item = cls.build_json_resource_obj( serializer_fields, - serializer_resource, - nested_resource_instance, - resource_type, - getattr(serializer, '_poly_force_type_resolution', False) + serializer_data, + relation_instance, + relation_type, + getattr(field, '_poly_force_type_resolution', False) + ) + included_cache[new_item['type']][new_item['id']] = utils.format_field_names( + new_item ) - included_cache[new_item['type']][new_item['id']] = \ - utils.format_field_names(new_item) cls.extract_included( serializer_fields, - serializer_resource, - nested_resource_instance, + serializer_data, + relation_instance, new_included_resources, included_cache, ) - if isinstance(field, Serializer): - relation_type = utils.get_resource_type_from_serializer(field) - - # Get the serializer fields - serializer_fields = utils.get_serializer_fields(field) - if serializer_data: - new_item = cls.build_json_resource_obj( - serializer_fields, - serializer_data, - relation_instance, - relation_type, - getattr(field, '_poly_force_type_resolution', False) - ) - included_cache[new_item['type']][new_item['id']] = utils.format_field_names( - new_item - ) - cls.extract_included( - serializer_fields, - serializer_data, - relation_instance, - new_included_resources, - included_cache, - ) - @classmethod def extract_meta(cls, serializer, resource): """ diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index be0dcace..1b8d6335 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -15,6 +15,7 @@ get_resource_type_from_serializer ) +from rest_framework_json_api.settings import json_api_settings, RELATIONS_RENDERING_STRATEGY class ResourceIdentifierObjectSerializer(BaseSerializer): default_error_messages = { @@ -195,7 +196,8 @@ def _get_field_representation(self, field, instance): is_included = field.source in get_included_resources(request) if not is_included and \ isinstance(field, ModelSerializer) and \ - hasattr(instance, field.source + '_id'): + hasattr(instance, field.source + '_id') and \ + json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY == RELATIONS_RENDERING_STRATEGY: attribute = getattr(instance, field.source + '_id') if attribute is None: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 1385630c..b39bbdcc 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -6,14 +6,19 @@ from django.conf import settings from django.core.signals import setting_changed +import warnings JSON_API_SETTINGS_PREFIX = 'JSON_API_' +RELATIONS_RENDERING_STRATEGY = 'RELATIONS' +ATTRIBUTE_RENDERING_STRATEGY = 'ATTRIBUTE' + DEFAULTS = { 'FORMAT_FIELD_NAMES': False, 'FORMAT_TYPES': False, 'PLURALIZE_TYPES': False, 'UNIFORM_EXCEPTIONS': False, + 'NESTED_SERIALIZERS_RENDERING_STRATEGY': RELATIONS_RENDERING_STRATEGY } @@ -27,6 +32,21 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): self.defaults = defaults self.user_settings = user_settings + value = getattr( + self.user_settings, + JSON_API_SETTINGS_PREFIX + 'NESTED_SERIALIZERS_RENDERING_STRATEGY', + self.defaults['NESTED_SERIALIZERS_RENDERING_STRATEGY']) + + if value not in (RELATIONS_RENDERING_STRATEGY, ATTRIBUTE_RENDERING_STRATEGY): + raise AttributeError("Invalid value '%s' for JSON API setting NESTED_SERIALIZERS_RENDERING_STRATEGY" % + value) + if value == RELATIONS_RENDERING_STRATEGY and \ + not hasattr(self.user_settings, JSON_API_SETTINGS_PREFIX + 'NESTED_SERIALIZERS_RENDERING_STRATEGY'): + warnings.warn(DeprecationWarning( + "Rendering nested serializers in relations by default is deprecated and will be " + "changed in future releases. Please, use ResourceRelatedField or set " + "JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY to RELATIONS")) + def __getattr__(self, attr): if attr not in self.defaults: raise AttributeError("Invalid JSON API setting: '%s'" % attr) From 49c479e6ff7ce9b94622ca6927830cb2b0db74a1 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Wed, 25 Mar 2020 17:51:25 +0300 Subject: [PATCH 02/18] f code style --- example/tests/test_rendering_strategies.py | 2 -- rest_framework_json_api/renderers.py | 14 +++++++------- rest_framework_json_api/serializers.py | 4 +++- rest_framework_json_api/settings.py | 7 ++++--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/example/tests/test_rendering_strategies.py b/example/tests/test_rendering_strategies.py index add08b84..6fc125ab 100644 --- a/example/tests/test_rendering_strategies.py +++ b/example/tests/test_rendering_strategies.py @@ -1,7 +1,5 @@ from __future__ import absolute_import -import json - from django.utils import timezone from rest_framework.reverse import reverse diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 7209edea..771f4dcf 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -53,7 +53,7 @@ def extract_attributes(cls, fields, resource): Builds the `attributes` object of the JSON API resource object. """ data = OrderedDict() - nested_serializers_rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON API so remove it from attributes if field_name == 'id': @@ -68,9 +68,9 @@ def extract_attributes(cls, fields, resource): continue if isinstance(field, BaseSerializer): - if nested_serializers_rendering_strategy == RELATIONS_RENDERING_STRATEGY: + if rendering_strategy == RELATIONS_RENDERING_STRATEGY: continue - elif nested_serializers_rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: + elif rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: pass # Skip read_only attribute fields when `resource` is an empty @@ -97,7 +97,7 @@ def extract_relationships(cls, fields, resource, resource_instance): from rest_framework_json_api.relations import ResourceRelatedField data = OrderedDict() - nested_serializers_rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -252,7 +252,7 @@ def extract_relationships(cls, fields, resource, resource_instance): }) continue - if nested_serializers_rendering_strategy == RELATIONS_RENDERING_STRATEGY: + if rendering_strategy == RELATIONS_RENDERING_STRATEGY: if isinstance(field, ListSerializer): resolved, relation_instance = utils.get_relation_instance( resource_instance, source, field.parent @@ -337,7 +337,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource included_serializers = utils.get_included_serializers(current_serializer) included_resources = copy.copy(included_resources) included_resources = [inflection.underscore(value) for value in included_resources] - nested_serializers_rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY for field_name, field in iter(fields.items()): # Skip URL field @@ -392,7 +392,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource for key in included_resources if field_name == key.split('.')[0]] - if nested_serializers_rendering_strategy == RELATIONS_RENDERING_STRATEGY: + if rendering_strategy == RELATIONS_RENDERING_STRATEGY: if isinstance(field, ListSerializer): serializer = field.child relation_type = utils.get_resource_type_from_serializer(serializer) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index fddb457f..90196269 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -17,6 +17,7 @@ from rest_framework_json_api.settings import json_api_settings, RELATIONS_RENDERING_STRATEGY + class ResourceIdentifierObjectSerializer(BaseSerializer): default_error_messages = { 'incorrect_model_type': _( @@ -194,10 +195,11 @@ def to_representation(self, instance): def _get_field_representation(self, field, instance): request = self.context.get('request') is_included = field.source in get_included_resources(request) + rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY if not is_included and \ isinstance(field, ModelSerializer) and \ hasattr(instance, field.source + '_id') and \ - json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY == RELATIONS_RENDERING_STRATEGY: + rendering_strategy == RELATIONS_RENDERING_STRATEGY: attribute = getattr(instance, field.source + '_id') if attribute is None: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index b39bbdcc..18569a3e 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -38,10 +38,11 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): self.defaults['NESTED_SERIALIZERS_RENDERING_STRATEGY']) if value not in (RELATIONS_RENDERING_STRATEGY, ATTRIBUTE_RENDERING_STRATEGY): - raise AttributeError("Invalid value '%s' for JSON API setting NESTED_SERIALIZERS_RENDERING_STRATEGY" % - value) + raise AttributeError("Invalid value '%s' for JSON API setting " + "NESTED_SERIALIZERS_RENDERING_STRATEGY" % value) if value == RELATIONS_RENDERING_STRATEGY and \ - not hasattr(self.user_settings, JSON_API_SETTINGS_PREFIX + 'NESTED_SERIALIZERS_RENDERING_STRATEGY'): + not hasattr(self.user_settings, + JSON_API_SETTINGS_PREFIX + 'NESTED_SERIALIZERS_RENDERING_STRATEGY'): warnings.warn(DeprecationWarning( "Rendering nested serializers in relations by default is deprecated and will be " "changed in future releases. Please, use ResourceRelatedField or set " From 0b099c2fa17a0c9d5ff1d05a1740eff06b42ec14 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Wed, 25 Mar 2020 19:01:54 +0300 Subject: [PATCH 03/18] f ignore deprecation warning --- pytest.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index 2c69372d..8f0531a7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = - error::DeprecationWarning - error::PendingDeprecationWarning + ignore::DeprecationWarning + ignore::PendingDeprecationWarning From 5ddcf69403d2b24e75f0ed6c27f75800a74d16c0 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Thu, 26 Mar 2020 02:12:47 +0300 Subject: [PATCH 04/18] f cover settings file with test --- example/tests/test_rendering_strategies.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/example/tests/test_rendering_strategies.py b/example/tests/test_rendering_strategies.py index 6fc125ab..4ee5b2a2 100644 --- a/example/tests/test_rendering_strategies.py +++ b/example/tests/test_rendering_strategies.py @@ -1,18 +1,20 @@ from __future__ import absolute_import +import pytest from django.utils import timezone from rest_framework.reverse import reverse +from rest_framework_json_api.settings import JSONAPISettings from . import TestBase from example.models import Author, Blog, Comment, Entry from django.test import override_settings -class TestResourceRelatedField(TestBase): +class TestRenderingStrategy(TestBase): list_url = reverse('authors-nested-list') def setUp(self): - super(TestResourceRelatedField, self).setUp() + super(TestRenderingStrategy, self).setUp() self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, @@ -76,3 +78,16 @@ def test_attribute_rendering_strategy(self): } } assert expected == response.json() + + +class TestRenderingStrategySettings(TestBase): + + def test_deprecation(self): + with pytest.deprecated_call(): + JSONAPISettings() + + def test_invalid_strategy(self): + class Settings: + JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY = 'SOME_INVALID_STRATEGY' + with pytest.raises(AttributeError): + JSONAPISettings(user_settings=Settings()) From 5a9f1e5982055a73917b6949237f6040477d3461 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Thu, 26 Mar 2020 02:25:06 +0300 Subject: [PATCH 05/18] f add changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dc90b7..60a691b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [3.2.0] - pending + +### Added + +* Added support for serializiing complex structures as attributes. For details please reffer to #769 + ## [3.1.0] - 2020-02-08 ### Added From db139e05a999d3cc1fa5f0e00771875d776b2e9c Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Thu, 26 Mar 2020 14:19:59 +0300 Subject: [PATCH 06/18] + tests for relations strategy --- example/serializers.py | 8 ++ example/tests/test_rendering_strategies.py | 117 ++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index 8f4ad726..9db1ff23 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -261,6 +261,10 @@ def get_first_entry(self, obj): class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): entry = EntryDRFSerializers() + included_serializers = { + 'entry': 'example.serializers.EntryDRFSerializers' + } + class Meta: model = Comment exclude = ('created_at', 'modified_at', 'author') @@ -270,6 +274,10 @@ class Meta: class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): comments = CommentWithNestedFieldsSerializer(many=True) + included_serializers = { + 'comments': 'example.serializers.CommentWithNestedFieldsSerializer' + } + class Meta: model = Author fields = ('name', 'email', 'comments') diff --git a/example/tests/test_rendering_strategies.py b/example/tests/test_rendering_strategies.py index 4ee5b2a2..081befdf 100644 --- a/example/tests/test_rendering_strategies.py +++ b/example/tests/test_rendering_strategies.py @@ -4,7 +4,8 @@ from django.utils import timezone from rest_framework.reverse import reverse -from rest_framework_json_api.settings import JSONAPISettings +from rest_framework_json_api.settings import JSONAPISettings, \ + ATTRIBUTE_RENDERING_STRATEGY, RELATIONS_RENDERING_STRATEGY from . import TestBase from example.models import Author, Blog, Comment, Entry from django.test import override_settings @@ -39,7 +40,7 @@ def setUp(self): ) def test_attribute_rendering_strategy(self): - with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY='ATTRIBUTE'): + with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=ATTRIBUTE_RENDERING_STRATEGY): response = self.client.get(self.list_url) expected = { @@ -79,6 +80,118 @@ def test_attribute_rendering_strategy(self): } assert expected == response.json() + def test_relations_rendering_strategy(self): + with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): + response = self.client.get(self.list_url) + + expected = { + "links": { + "first": "http://testserver/authors-nested?page%5Bnumber%5D=1", + "last": "http://testserver/authors-nested?page%5Bnumber%5D=5", + "next": "http://testserver/authors-nested?page%5Bnumber%5D=2", + "prev": None + }, + "data": [ + { + "type": "authors", + "id": "1", + "attributes": { + "name": "some_author1", + "email": "some_author1@example.org" + }, + "relationships": { + "comments": { + "data": [ + { + "type": "comments", + "id": "1" + } + ] + } + } + } + ], + "meta": { + "pagination": { + "page": 1, + "pages": 5, + "count": 5 + } + } + } + assert expected == response.json() + + def test_relations_rendering_strategy_included(self): + with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): + response = self.client.get(self.list_url, data={'include': 'comments,comments.entry'}) + + expected = { + "links": { + "first": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=1", + "last": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=5", + "next": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=2", + "prev": None + }, + "data": [ + { + "type": "authors", + "id": "1", + "attributes": { + "name": "some_author1", + "email": "some_author1@example.org" + }, + "relationships": { + "comments": { + "data": [ + { + "type": "comments", + "id": "1" + } + ] + } + } + } + ], + "included": [ + { + "type": "comments", + "id": "1", + "attributes": { + "body": "testing one two three" + }, + "relationships": { + "entry": { + "data": { + "type": "entries", + "id": "1" + } + } + } + }, + { + "type": "entries", + "id": "1", + "attributes": {}, + "relationships": { + "tags": { + "data": [] + } + }, + "links": { + "self": "http://testserver/drf-blogs/1" + } + } + ], + "meta": { + "pagination": { + "page": 1, + "pages": 5, + "count": 5 + } + } + } + assert expected == response.json() + class TestRenderingStrategySettings(TestBase): From cf31527673321f4d92fb7ecbe0858706fb16c84a Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Thu, 26 Mar 2020 14:30:54 +0300 Subject: [PATCH 07/18] f codestyle --- example/tests/test_rendering_strategies.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/example/tests/test_rendering_strategies.py b/example/tests/test_rendering_strategies.py index 081befdf..d3ffbce8 100644 --- a/example/tests/test_rendering_strategies.py +++ b/example/tests/test_rendering_strategies.py @@ -40,7 +40,8 @@ def setUp(self): ) def test_attribute_rendering_strategy(self): - with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=ATTRIBUTE_RENDERING_STRATEGY): + with override_settings( + JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=ATTRIBUTE_RENDERING_STRATEGY): response = self.client.get(self.list_url) expected = { @@ -81,7 +82,8 @@ def test_attribute_rendering_strategy(self): assert expected == response.json() def test_relations_rendering_strategy(self): - with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): + with override_settings( + JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): response = self.client.get(self.list_url) expected = { @@ -122,14 +124,15 @@ def test_relations_rendering_strategy(self): assert expected == response.json() def test_relations_rendering_strategy_included(self): - with override_settings(JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): + with override_settings( + JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): response = self.client.get(self.list_url, data={'include': 'comments,comments.entry'}) expected = { "links": { - "first": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=1", - "last": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=5", - "next": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=2", + "first": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=1", # NoQA + "last": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=5", # NoQA + "next": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=2", # NoQA "prev": None }, "data": [ From 387500ad3732e1f857e7edca3734366eeffa9d24 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Tue, 31 Mar 2020 19:18:04 +0300 Subject: [PATCH 08/18] f included --- rest_framework_json_api/renderers.py | 198 ++++++++++++++------------- 1 file changed, 102 insertions(+), 96 deletions(-) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 771f4dcf..d0b2bdb6 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -114,10 +114,14 @@ def extract_relationships(cls, fields, resource, resource_instance): # Skip fields without relations if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) + field, (relations.RelatedField, relations.ManyRelatedField) ): continue + if isinstance(field, BaseSerializer) and \ + rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: + continue + source = field.source relation_type = utils.get_related_resource_type(field) @@ -252,56 +256,55 @@ def extract_relationships(cls, fields, resource, resource_instance): }) continue - if rendering_strategy == RELATIONS_RENDERING_STRATEGY: - if isinstance(field, ListSerializer): + if isinstance(field, ListSerializer): + resolved, relation_instance = utils.get_relation_instance( + resource_instance, source, field.parent + ) + if not resolved: + continue + + relation_data = list() + + serializer_data = resource.get(field_name) + resource_instance_queryset = list(relation_instance) + if isinstance(serializer_data, list): + for position in range(len(serializer_data)): + nested_resource_instance = resource_instance_queryset[position] + nested_resource_instance_type = ( + relation_type or + utils.get_resource_type_from_instance(nested_resource_instance) + ) + + relation_data.append(OrderedDict([ + ('type', nested_resource_instance_type), + ('id', encoding.force_str(nested_resource_instance.pk)) + ])) + + data.update({field_name: {'data': relation_data}}) + continue + + if isinstance(field, Serializer): + relation_instance_id = getattr(resource_instance, source + "_id", None) + if not relation_instance_id: resolved, relation_instance = utils.get_relation_instance( resource_instance, source, field.parent ) if not resolved: continue - relation_data = list() - - serializer_data = resource.get(field_name) - resource_instance_queryset = list(relation_instance) - if isinstance(serializer_data, list): - for position in range(len(serializer_data)): - nested_resource_instance = resource_instance_queryset[position] - nested_resource_instance_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) - ) + if relation_instance is not None: + relation_instance_id = relation_instance.pk - relation_data.append(OrderedDict([ - ('type', nested_resource_instance_type), - ('id', encoding.force_str(nested_resource_instance.pk)) - ])) - - data.update({field_name: {'data': relation_data}}) - continue - - if isinstance(field, Serializer): - relation_instance_id = getattr(resource_instance, source + "_id", None) - if not relation_instance_id: - resolved, relation_instance = utils.get_relation_instance( - resource_instance, source, field.parent - ) - if not resolved: - continue - - if relation_instance is not None: - relation_instance_id = relation_instance.pk - - data.update({ - field_name: { - 'data': ( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_str(relation_instance_id)) - ]) if resource.get(field_name) else None) - } - }) - continue + data.update({ + field_name: { + 'data': ( + OrderedDict([ + ('type', relation_type), + ('id', encoding.force_str(relation_instance_id)) + ]) if resource.get(field_name) else None) + } + }) + continue return utils.format_field_names(data) @@ -344,12 +347,16 @@ def extract_included(cls, fields, resource, resource_instance, included_resource if field_name == api_settings.URL_FIELD_NAME: continue - # Skip fields without relations or serialized data + # Skip fields without relations if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) + field, (relations.RelatedField, relations.ManyRelatedField) ): continue + if isinstance(field, BaseSerializer) and \ + rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: + continue + try: included_resources.remove(field_name) except ValueError: @@ -392,66 +399,65 @@ def extract_included(cls, fields, resource, resource_instance, included_resource for key in included_resources if field_name == key.split('.')[0]] - if rendering_strategy == RELATIONS_RENDERING_STRATEGY: - if isinstance(field, ListSerializer): - serializer = field.child - relation_type = utils.get_resource_type_from_serializer(serializer) - relation_queryset = list(relation_instance) - - if serializer_data: - for position in range(len(serializer_data)): - serializer_resource = serializer_data[position] - nested_resource_instance = relation_queryset[position] - resource_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) - ) - serializer_fields = utils.get_serializer_fields( - serializer.__class__( - nested_resource_instance, context=serializer.context - ) - ) - new_item = cls.build_json_resource_obj( - serializer_fields, - serializer_resource, - nested_resource_instance, - resource_type, - getattr(serializer, '_poly_force_type_resolution', False) - ) - included_cache[new_item['type']][new_item['id']] = \ - utils.format_field_names(new_item) - cls.extract_included( - serializer_fields, - serializer_resource, - nested_resource_instance, - new_included_resources, - included_cache, + if isinstance(field, ListSerializer): + serializer = field.child + relation_type = utils.get_resource_type_from_serializer(serializer) + relation_queryset = list(relation_instance) + + if serializer_data: + for position in range(len(serializer_data)): + serializer_resource = serializer_data[position] + nested_resource_instance = relation_queryset[position] + resource_type = ( + relation_type or + utils.get_resource_type_from_instance(nested_resource_instance) + ) + serializer_fields = utils.get_serializer_fields( + serializer.__class__( + nested_resource_instance, context=serializer.context ) - - if isinstance(field, Serializer): - relation_type = utils.get_resource_type_from_serializer(field) - - # Get the serializer fields - serializer_fields = utils.get_serializer_fields(field) - if serializer_data: + ) new_item = cls.build_json_resource_obj( serializer_fields, - serializer_data, - relation_instance, - relation_type, - getattr(field, '_poly_force_type_resolution', False) - ) - included_cache[new_item['type']][new_item['id']] = utils.format_field_names( - new_item + serializer_resource, + nested_resource_instance, + resource_type, + getattr(serializer, '_poly_force_type_resolution', False) ) + included_cache[new_item['type']][new_item['id']] = \ + utils.format_field_names(new_item) cls.extract_included( serializer_fields, - serializer_data, - relation_instance, + serializer_resource, + nested_resource_instance, new_included_resources, included_cache, ) + if isinstance(field, Serializer): + relation_type = utils.get_resource_type_from_serializer(field) + + # Get the serializer fields + serializer_fields = utils.get_serializer_fields(field) + if serializer_data: + new_item = cls.build_json_resource_obj( + serializer_fields, + serializer_data, + relation_instance, + relation_type, + getattr(field, '_poly_force_type_resolution', False) + ) + included_cache[new_item['type']][new_item['id']] = utils.format_field_names( + new_item + ) + cls.extract_included( + serializer_fields, + serializer_data, + relation_instance, + new_included_resources, + included_cache, + ) + @classmethod def extract_meta(cls, serializer, resource): """ From 9071b4fa88f4748c0c282bb4d2609fbe03b7a4c7 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Tue, 31 Mar 2020 23:42:25 +0300 Subject: [PATCH 09/18] f wrong check --- rest_framework_json_api/renderers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index d0b2bdb6..d9cfcd62 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -114,7 +114,7 @@ def extract_relationships(cls, fields, resource, resource_instance): # Skip fields without relations if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) + field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) ): continue @@ -349,7 +349,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource # Skip fields without relations if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) + field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) ): continue From ab9a1ae2ae7c48f315030321108e69a8a14298e8 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Fri, 10 Apr 2020 19:08:39 +0300 Subject: [PATCH 10/18] f tests f json_api settings f pytest.ini --- example/serializers.py | 25 --- example/tests/test_rendering_strategies.py | 209 --------------------- example/tests/unit/test_renderers.py | 90 ++++++++- example/urls_test.py | 4 +- example/views.py | 8 +- pytest.ini | 4 +- rest_framework_json_api/renderers.py | 19 +- rest_framework_json_api/serializers.py | 4 +- rest_framework_json_api/settings.py | 17 +- 9 files changed, 108 insertions(+), 272 deletions(-) delete mode 100644 example/tests/test_rendering_strategies.py diff --git a/example/serializers.py b/example/serializers.py index 9db1ff23..cc24efb0 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -258,31 +258,6 @@ def get_first_entry(self, obj): return obj.entries.first() -class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): - entry = EntryDRFSerializers() - - included_serializers = { - 'entry': 'example.serializers.EntryDRFSerializers' - } - - class Meta: - model = Comment - exclude = ('created_at', 'modified_at', 'author') - # fields = ('entry', 'body', 'author',) - - -class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): - comments = CommentWithNestedFieldsSerializer(many=True) - - included_serializers = { - 'comments': 'example.serializers.CommentWithNestedFieldsSerializer' - } - - class Meta: - model = Author - fields = ('name', 'email', 'comments') - - class WriterSerializer(serializers.ModelSerializer): included_serializers = { 'bio': AuthorBioSerializer diff --git a/example/tests/test_rendering_strategies.py b/example/tests/test_rendering_strategies.py deleted file mode 100644 index d3ffbce8..00000000 --- a/example/tests/test_rendering_strategies.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import absolute_import - -import pytest -from django.utils import timezone -from rest_framework.reverse import reverse - -from rest_framework_json_api.settings import JSONAPISettings, \ - ATTRIBUTE_RENDERING_STRATEGY, RELATIONS_RENDERING_STRATEGY -from . import TestBase -from example.models import Author, Blog, Comment, Entry -from django.test import override_settings - - -class TestRenderingStrategy(TestBase): - list_url = reverse('authors-nested-list') - - def setUp(self): - super(TestRenderingStrategy, 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 - ) - for i in range(1, 6): - name = 'some_author{}'.format(i) - self.entry.authors.add( - Author.objects.create(name=name, email='{}@example.org'.format(name)) - ) - - self.comment = Comment.objects.create( - entry=self.entry, - body='testing one two three', - author=Author.objects.first() - ) - - def test_attribute_rendering_strategy(self): - with override_settings( - JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=ATTRIBUTE_RENDERING_STRATEGY): - response = self.client.get(self.list_url) - - expected = { - "links": { - "first": "http://testserver/authors-nested?page%5Bnumber%5D=1", - "last": "http://testserver/authors-nested?page%5Bnumber%5D=5", - "next": "http://testserver/authors-nested?page%5Bnumber%5D=2", - "prev": None - }, - "data": [ - { - "type": "authors", - "id": "1", - "attributes": { - "name": "some_author1", - "email": "some_author1@example.org", - "comments": [ - { - "id": 1, - "entry": { - "tags": [], - "url": "http://testserver/drf-blogs/1" - }, - "body": "testing one two three" - } - ] - } - } - ], - "meta": { - "pagination": { - "page": 1, - "pages": 5, - "count": 5 - } - } - } - assert expected == response.json() - - def test_relations_rendering_strategy(self): - with override_settings( - JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): - response = self.client.get(self.list_url) - - expected = { - "links": { - "first": "http://testserver/authors-nested?page%5Bnumber%5D=1", - "last": "http://testserver/authors-nested?page%5Bnumber%5D=5", - "next": "http://testserver/authors-nested?page%5Bnumber%5D=2", - "prev": None - }, - "data": [ - { - "type": "authors", - "id": "1", - "attributes": { - "name": "some_author1", - "email": "some_author1@example.org" - }, - "relationships": { - "comments": { - "data": [ - { - "type": "comments", - "id": "1" - } - ] - } - } - } - ], - "meta": { - "pagination": { - "page": 1, - "pages": 5, - "count": 5 - } - } - } - assert expected == response.json() - - def test_relations_rendering_strategy_included(self): - with override_settings( - JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY=RELATIONS_RENDERING_STRATEGY): - response = self.client.get(self.list_url, data={'include': 'comments,comments.entry'}) - - expected = { - "links": { - "first": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=1", # NoQA - "last": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=5", # NoQA - "next": "http://testserver/authors-nested?include=comments%2Ccomments.entry&page%5Bnumber%5D=2", # NoQA - "prev": None - }, - "data": [ - { - "type": "authors", - "id": "1", - "attributes": { - "name": "some_author1", - "email": "some_author1@example.org" - }, - "relationships": { - "comments": { - "data": [ - { - "type": "comments", - "id": "1" - } - ] - } - } - } - ], - "included": [ - { - "type": "comments", - "id": "1", - "attributes": { - "body": "testing one two three" - }, - "relationships": { - "entry": { - "data": { - "type": "entries", - "id": "1" - } - } - } - }, - { - "type": "entries", - "id": "1", - "attributes": {}, - "relationships": { - "tags": { - "data": [] - } - }, - "links": { - "self": "http://testserver/drf-blogs/1" - } - } - ], - "meta": { - "pagination": { - "page": 1, - "pages": 5, - "count": 5 - } - } - } - assert expected == response.json() - - -class TestRenderingStrategySettings(TestBase): - - def test_deprecation(self): - with pytest.deprecated_call(): - JSONAPISettings() - - def test_invalid_strategy(self): - class Settings: - JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY = 'SOME_INVALID_STRATEGY' - with pytest.raises(AttributeError): - JSONAPISettings(user_settings=Settings()) diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index e432704d..174cbe63 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -1,11 +1,14 @@ import json import pytest +from django.test import override_settings +from django.utils import timezone +from example.tests import TestBase from rest_framework_json_api import serializers, views from rest_framework_json_api.renderers import JSONRenderer -from example.models import Author, Comment, Entry +from example.models import Author, Comment, Entry, Blog # serializers @@ -38,6 +41,31 @@ class JSONAPIMeta: included_resources = ('related_models',) +class EntryDRFSerializers(serializers.ModelSerializer): + + class Meta: + model = Entry + fields = ('headline', 'body_text') + read_only_fields = ('tags',) + + +class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): + entry = EntryDRFSerializers() + + class Meta: + model = Comment + exclude = ('created_at', 'modified_at', 'author') + # fields = ('entry', 'body', 'author',) + + +class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): + comments = CommentWithNestedFieldsSerializer(many=True) + + class Meta: + model = Author + fields = ('name', 'email', 'comments') + + # views class DummyTestViewSet(views.ModelViewSet): queryset = Entry.objects.all() @@ -49,6 +77,12 @@ class ReadOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): serializer_class = DummyTestSerializer +class AuthorWithNestedFieldsViewSet(views.ModelViewSet): + queryset = Author.objects.all() + serializer_class = AuthorWithNestedFieldsSerializer + resource_name = 'authors' + + def render_dummy_test_serialized_view(view_class, instance): serializer = view_class.serializer_class(instance=instance) renderer = JSONRenderer() @@ -138,3 +172,57 @@ def test_extract_relation_instance(comment): field=serializer.fields['blog'], resource_instance=comment ) assert got == comment.entry.blog + + +class TestRenderingStrategy(TestBase): + + def setUp(self): + super(TestRenderingStrategy, 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.author = Author.objects.create(name='some_author', email='some_author@example.org') + self.entry.authors.add(self.author) + + self.comment = Comment.objects.create( + entry=self.entry, + body='testing one two three', + author=Author.objects.first() + ) + + def test_attribute_rendering_strategy(self): + with override_settings( + JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): + rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, self.author) + result = json.loads(rendered.decode()) + + expected = { + "data": { + "type": "authors", + "id": "1", + "attributes": { + "name": "some_author", + "email": "some_author@example.org", + "comments": [ + { + "id": 1, + "entry": { + 'headline': 'headline', + 'body_text': 'body_text', + }, + "body": "testing one two three" + } + ] + } + } + } + self.assertDictEqual(expected, result) diff --git a/example/urls_test.py b/example/urls_test.py index dc9f9558..020ab2f3 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -18,8 +18,7 @@ NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset, - AuthorWithNestedFieldsViewSet + ProjectViewset ) router = routers.DefaultRouter(trailing_slash=False) @@ -33,7 +32,6 @@ router.register(r'filterset-entries', FiltersetEntryViewSet, 'filterset-entry') router.register(r'nofilterset-entries', NoFiltersetEntryViewSet, 'nofilterset-entry') router.register(r'authors', AuthorViewSet) -router.register(r'authors-nested', AuthorWithNestedFieldsViewSet, 'authors-nested') router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) router.register(r'projects', ProjectViewset) diff --git a/example/views.py b/example/views.py index 4b881db4..90272bee 100644 --- a/example/views.py +++ b/example/views.py @@ -23,8 +23,7 @@ EntryDRFSerializers, EntrySerializer, ProjectSerializer, - ProjectTypeSerializer, - AuthorWithNestedFieldsSerializer) + ProjectTypeSerializer) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -175,11 +174,6 @@ class AuthorViewSet(ModelViewSet): serializer_class = AuthorSerializer -class AuthorWithNestedFieldsViewSet(ModelViewSet): - queryset = Author.objects.all() - serializer_class = AuthorWithNestedFieldsSerializer - - class CommentViewSet(ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer diff --git a/pytest.ini b/pytest.ini index 8f0531a7..2c69372d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning + error::DeprecationWarning + error::PendingDeprecationWarning diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index d9cfcd62..c2293407 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -53,7 +53,7 @@ def extract_attributes(cls, fields, resource): Builds the `attributes` object of the JSON API resource object. """ data = OrderedDict() - rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON API so remove it from attributes if field_name == 'id': @@ -67,11 +67,8 @@ def extract_attributes(cls, fields, resource): ): continue - if isinstance(field, BaseSerializer): - if rendering_strategy == RELATIONS_RENDERING_STRATEGY: - continue - elif rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: - pass + if isinstance(field, BaseSerializer) and not render_nested_as_attribute: + continue # Skip read_only attribute fields when `resource` is an empty # serializer. Prevents the "Raw Data" form of the browsable API @@ -97,7 +94,7 @@ def extract_relationships(cls, fields, resource, resource_instance): from rest_framework_json_api.relations import ResourceRelatedField data = OrderedDict() - rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -118,8 +115,7 @@ def extract_relationships(cls, fields, resource, resource_instance): ): continue - if isinstance(field, BaseSerializer) and \ - rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: + if isinstance(field, BaseSerializer) and render_nested_as_attribute: continue source = field.source @@ -340,7 +336,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource included_serializers = utils.get_included_serializers(current_serializer) included_resources = copy.copy(included_resources) included_resources = [inflection.underscore(value) for value in included_resources] - rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE for field_name, field in iter(fields.items()): # Skip URL field @@ -353,8 +349,7 @@ def extract_included(cls, fields, resource, resource_instance, included_resource ): continue - if isinstance(field, BaseSerializer) and \ - rendering_strategy == ATTRIBUTE_RENDERING_STRATEGY: + if isinstance(field, BaseSerializer) and render_nested_as_attribute: continue try: diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 90196269..9b09c343 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -195,11 +195,11 @@ def to_representation(self, instance): def _get_field_representation(self, field, instance): request = self.context.get('request') is_included = field.source in get_included_resources(request) - rendering_strategy = json_api_settings.NESTED_SERIALIZERS_RENDERING_STRATEGY + render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE if not is_included and \ isinstance(field, ModelSerializer) and \ hasattr(instance, field.source + '_id') and \ - rendering_strategy == RELATIONS_RENDERING_STRATEGY: + not render_nested_as_attribute: attribute = getattr(instance, field.source + '_id') if attribute is None: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 18569a3e..669531fa 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -18,7 +18,7 @@ 'FORMAT_TYPES': False, 'PLURALIZE_TYPES': False, 'UNIFORM_EXCEPTIONS': False, - 'NESTED_SERIALIZERS_RENDERING_STRATEGY': RELATIONS_RENDERING_STRATEGY + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE': False } @@ -34,19 +34,14 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): value = getattr( self.user_settings, - JSON_API_SETTINGS_PREFIX + 'NESTED_SERIALIZERS_RENDERING_STRATEGY', - self.defaults['NESTED_SERIALIZERS_RENDERING_STRATEGY']) - - if value not in (RELATIONS_RENDERING_STRATEGY, ATTRIBUTE_RENDERING_STRATEGY): - raise AttributeError("Invalid value '%s' for JSON API setting " - "NESTED_SERIALIZERS_RENDERING_STRATEGY" % value) - if value == RELATIONS_RENDERING_STRATEGY and \ - not hasattr(self.user_settings, - JSON_API_SETTINGS_PREFIX + 'NESTED_SERIALIZERS_RENDERING_STRATEGY'): + JSON_API_SETTINGS_PREFIX + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE', + self.defaults['SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE']) + + if not value and not hasattr(self.user_settings, JSON_API_SETTINGS_PREFIX + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE'): warnings.warn(DeprecationWarning( "Rendering nested serializers in relations by default is deprecated and will be " "changed in future releases. Please, use ResourceRelatedField or set " - "JSON_API_NESTED_SERIALIZERS_RENDERING_STRATEGY to RELATIONS")) + "JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to False")) def __getattr__(self, attr): if attr not in self.defaults: From 65f9b158b44183cc70d446936d0d274bb8a1a067 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Tue, 14 Apr 2020 01:29:44 +0300 Subject: [PATCH 11/18] f pep --- rest_framework_json_api/renderers.py | 2 +- rest_framework_json_api/serializers.py | 2 +- rest_framework_json_api/settings.py | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index c2293407..8872e392 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -13,7 +13,7 @@ from rest_framework.relations import PKOnlyObject from rest_framework.serializers import BaseSerializer, ListSerializer, Serializer from rest_framework.settings import api_settings -from .settings import json_api_settings, RELATIONS_RENDERING_STRATEGY, ATTRIBUTE_RENDERING_STRATEGY +from .settings import json_api_settings import rest_framework_json_api from rest_framework_json_api import utils diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 9b09c343..583c8565 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -15,7 +15,7 @@ get_resource_type_from_serializer ) -from rest_framework_json_api.settings import json_api_settings, RELATIONS_RENDERING_STRATEGY +from rest_framework_json_api.settings import json_api_settings class ResourceIdentifierObjectSerializer(BaseSerializer): diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 669531fa..385b70fe 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -10,9 +10,6 @@ JSON_API_SETTINGS_PREFIX = 'JSON_API_' -RELATIONS_RENDERING_STRATEGY = 'RELATIONS' -ATTRIBUTE_RENDERING_STRATEGY = 'ATTRIBUTE' - DEFAULTS = { 'FORMAT_FIELD_NAMES': False, 'FORMAT_TYPES': False, @@ -32,12 +29,14 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): self.defaults = defaults self.user_settings = user_settings + field_name = JSON_API_SETTINGS_PREFIX + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE' + value = getattr( self.user_settings, - JSON_API_SETTINGS_PREFIX + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE', + field_name, self.defaults['SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE']) - if not value and not hasattr(self.user_settings, JSON_API_SETTINGS_PREFIX + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE'): + if not value and not hasattr(self.user_settings, field_name): warnings.warn(DeprecationWarning( "Rendering nested serializers in relations by default is deprecated and will be " "changed in future releases. Please, use ResourceRelatedField or set " From 67442ab2c796fb89a97da7c8db99782812074bd6 Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Tue, 14 Apr 2020 03:11:22 +0300 Subject: [PATCH 12/18] f rewrite complex drf errors structures into JSON-API format --- example/tests/test_nested_errors.py | 202 ++++++++++++++++++++++++++++ rest_framework_json_api/utils.py | 55 +++++--- 2 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 example/tests/test_nested_errors.py diff --git a/example/tests/test_nested_errors.py b/example/tests/test_nested_errors.py new file mode 100644 index 00000000..8ba0869f --- /dev/null +++ b/example/tests/test_nested_errors.py @@ -0,0 +1,202 @@ +import json + +import pytest +from django.conf.urls import url +from django.test import override_settings +from django.urls import reverse + +# from example import urls_test +from example.models import Blog +from example.tests import TestBase +from rest_framework_json_api import serializers +from rest_framework import views + + +# serializers +class CommentAttachmentSerializer(serializers.Serializer): + data = serializers.CharField(allow_null=False, required=True) + + def validate_data(self, value): + if value and len(value) < 10: + raise serializers.ValidationError('Too short data') + + +class CommentSerializer(serializers.Serializer): + attachments = CommentAttachmentSerializer(many=True, required=False) + attachment = CommentAttachmentSerializer(required=False) + body = serializers.CharField(allow_null=False, required=True) + + +class EntrySerializer(serializers.Serializer): + blog = serializers.IntegerField() + comments = CommentSerializer(many=True, required=False) + comment = CommentSerializer(required=False) + headline = serializers.CharField(allow_null=True, required=True) + body_text = serializers.CharField() + + def validate(self, attrs): + body_text = attrs['body_text'] + if len(body_text) < 5: + raise serializers.ValidationError({'body_text': 'Too short'}) + + +# view +class DummyTestView(views.APIView): + serializer_class = EntrySerializer + resource_name = 'entries' + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + +urlpatterns = [ + url(r'^entries-nested/$', DummyTestView.as_view(), + name='entries-nested-list') +] + + +@override_settings(ROOT_URLCONF=__name__) +@pytest.mark.filterwarnings('ignore:Rendering nested') +class TestNestedErrors(TestBase): + + def setUp(self): + super(TestNestedErrors, self).setUp() + self.url = reverse('entries-nested-list') + self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + + def perform_error_test(self, data, expected_pointer): + with override_settings( + JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): + response = self.client.post(self.url, data=data) + + errors = response.data + + assert len(errors) == 1 + assert errors[0]['source']['pointer'] == expected_pointer + + def test_first_level_attribute_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body_text', + } + } + } + self.perform_error_test(data, '/data/attributes/headline') + + def test_first_level_custom_attribute_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body', + 'headline': 'headline' + } + } + } + with override_settings(JSON_API_FORMAT_FIELD_NAMES='underscore'): + self.perform_error_test(data, '/data/attributes/body_text') + + def test_second_level_array_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + } + ] + } + } + } + + self.perform_error_test(data, '/data/attributes/comments/0/body') + + def test_second_level_dict_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comment': {} + } + } + } + + self.perform_error_test(data, '/data/attributes/comment/body') + + def test_third_level_array_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachments': [ + { + } + ] + } + ] + } + } + } + + self.perform_error_test(data, '/data/attributes/comments/0/attachments/0/data') + + def test_third_level_custom_array_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachments': [ + { + 'data': 'text' + } + ] + } + ] + } + } + } + + self.perform_error_test(data, '/data/attributes/comments/0/attachments/0/data') + + def test_third_level_dict_error(self): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': self.blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachment': {} + } + ] + } + } + } + + self.perform_error_test(data, '/data/attributes/comments/0/attachment/data') diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b3932651..5283c7bf 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -312,29 +312,34 @@ def format_drf_errors(response, context, exc): # handle generic errors. ValidationError('test') in a view for example if isinstance(response.data, list): for message in response.data: - errors.append(format_error_object(message, '/data', response)) + errors.extend(format_error_object(message, '/data', response)) # handle all errors thrown from serializers else: for field, error in response.data.items(): field = format_value(field) pointer = '/data/attributes/{}'.format(field) # see if they passed a dictionary to ValidationError manually + # The bit tricky problem is here. It is may be nested drf thing in format + # name: error_object, or it may be custom error thrown by user. I guess, + # if it is drf error, dict will always have single key if isinstance(error, dict): - errors.append(error) + if len(error) > 1: + errors.append(error) + else: + errors.extend(format_error_object(error, pointer, response)) elif isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer - errors.append(format_error_object(error, None, response)) + errors.extend(format_error_object(error, None, response)) elif isinstance(error, str): classes = inspect.getmembers(exceptions, inspect.isclass) # DRF sets the `field` to 'detail' for its own exceptions if isinstance(exc, tuple(x[1] for x in classes)): pointer = '/data' - errors.append(format_error_object(error, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) elif isinstance(error, list): - for message in error: - errors.append(format_error_object(message, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) else: - errors.append(format_error_object(error, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) context['view'].resource_name = 'errors' response.data = errors @@ -343,18 +348,32 @@ def format_drf_errors(response, context, exc): def format_error_object(message, pointer, response): - error_obj = { - 'detail': message, - 'status': encoding.force_str(response.status_code), - } - if pointer is not None: - error_obj['source'] = { - 'pointer': pointer, + errors = [] + if isinstance(message, dict): + for k, v in message.items(): + errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) + elif isinstance(message, list): + for num, error in enumerate(message): + if isinstance(error, (list, dict)): + new_pointer = pointer + '/{}'.format(num) + else: + new_pointer = pointer + if error: + errors.extend(format_error_object(error, new_pointer, response)) + else: + error_obj = { + 'detail': message, + 'status': encoding.force_str(response.status_code), } - code = getattr(message, "code", None) - if code is not None: - error_obj['code'] = code - return error_obj + if pointer is not None: + error_obj['source'] = { + 'pointer': pointer, + } + code = getattr(message, "code", None) + if code is not None: + error_obj['code'] = code + errors.append(error_obj) + return errors def format_errors(data): From 177bfbc8200b1936a7e6acc4211686f041b59daa Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Tue, 14 Apr 2020 03:32:30 +0300 Subject: [PATCH 13/18] f move DeprecationWarning from settings to metaclass, which ensures that DeprecationWarning raises whenever nested serializer is used without correct settings --- example/tests/test_nested_errors.py | 16 ++++++++++- pytest.ini | 1 + rest_framework_json_api/serializers.py | 40 ++++++++++++++++++++++++-- rest_framework_json_api/settings.py | 13 --------- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/example/tests/test_nested_errors.py b/example/tests/test_nested_errors.py index 8ba0869f..16d84cb6 100644 --- a/example/tests/test_nested_errors.py +++ b/example/tests/test_nested_errors.py @@ -57,7 +57,6 @@ def post(self, request, *args, **kwargs): @override_settings(ROOT_URLCONF=__name__) -@pytest.mark.filterwarnings('ignore:Rendering nested') class TestNestedErrors(TestBase): def setUp(self): @@ -200,3 +199,18 @@ def test_third_level_dict_error(self): } self.perform_error_test(data, '/data/attributes/comments/0/attachment/data') + + +@pytest.mark.filterwarning('default::DeprecationWarning:rest_framework_json_api.serializers') +def test_deprecation_warning(recwarn): + class DummyNestedSerializer(serializers.Serializer): + field = serializers.CharField() + + class DummySerializer(serializers.Serializer): + nested = DummyNestedSerializer(many=True) + + assert len(recwarn) == 1 + warning = recwarn.pop(DeprecationWarning) + assert warning + assert str(warning.message).startswith('Rendering') + diff --git a/pytest.ini b/pytest.ini index 2c69372d..ebf0e544 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,4 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning + ignore::DeprecationWarning:rest_framework_json_api.serializers \ No newline at end of file diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 583c8565..154d7aed 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,3 +1,5 @@ +import warnings + import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet @@ -117,8 +119,41 @@ def validate_path(serializer_class, field_path, path): super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) +class SerializerMetaclass(SerializerMetaclass): + + @classmethod + def _get_declared_fields(cls, bases, attrs): + fields = super()._get_declared_fields(bases, attrs) + setting_name = 'JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE' + for field_name, field in fields.items(): + if isinstance(field, BaseSerializer) and \ + not json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE and \ + not hasattr(json_api_settings.user_settings, setting_name): + clazz = '{}.{}'.format(attrs['__module__'], attrs['__qualname__']) + if isinstance(field, ListSerializer): + nested_class = type(field.child).__name__ + else: + nested_class = type(field).__name__ + + warnings.warn(DeprecationWarning( + "Rendering nested serializers in relations by default is deprecated and will " + "be changed in future releases. Please, use ResourceRelatedField instead of " + "{} in serializer {} or set JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE" + " to False".format(nested_class, clazz))) + return fields + + +# If user imports serializer from here we can catch class definition and check +# nested serializers for depricated use. Probably it is not bad idea to add +# sparse and included mixins to this definition, in case people want to use +# DRF-JA without models underlying. +class Serializer(Serializer, metaclass=SerializerMetaclass): + pass + + class HyperlinkedModelSerializer( - IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer + IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer, + metaclass=SerializerMetaclass ): """ A type of `ModelSerializer` that uses hyperlinked relationships instead @@ -134,7 +169,8 @@ class HyperlinkedModelSerializer( """ -class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, ModelSerializer): +class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, ModelSerializer, + metaclass=SerializerMetaclass): """ A `ModelSerializer` is just a regular `Serializer`, except that: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 385b70fe..e2766557 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -29,19 +29,6 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): self.defaults = defaults self.user_settings = user_settings - field_name = JSON_API_SETTINGS_PREFIX + 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE' - - value = getattr( - self.user_settings, - field_name, - self.defaults['SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE']) - - if not value and not hasattr(self.user_settings, field_name): - warnings.warn(DeprecationWarning( - "Rendering nested serializers in relations by default is deprecated and will be " - "changed in future releases. Please, use ResourceRelatedField or set " - "JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to False")) - def __getattr__(self, attr): if attr not in self.defaults: raise AttributeError("Invalid JSON API setting: '%s'" % attr) From fdc816698f47cd842be5ab3cbed3ff55cdf716fe Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Tue, 14 Apr 2020 03:39:40 +0300 Subject: [PATCH 14/18] f pep8 --- example/tests/test_nested_errors.py | 4 ---- rest_framework_json_api/settings.py | 1 - rest_framework_json_api/utils.py | 2 +- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/example/tests/test_nested_errors.py b/example/tests/test_nested_errors.py index 16d84cb6..37502e4d 100644 --- a/example/tests/test_nested_errors.py +++ b/example/tests/test_nested_errors.py @@ -1,11 +1,8 @@ -import json - import pytest from django.conf.urls import url from django.test import override_settings from django.urls import reverse -# from example import urls_test from example.models import Blog from example.tests import TestBase from rest_framework_json_api import serializers @@ -213,4 +210,3 @@ class DummySerializer(serializers.Serializer): warning = recwarn.pop(DeprecationWarning) assert warning assert str(warning.message).startswith('Rendering') - diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index e2766557..74e4e8d3 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -6,7 +6,6 @@ from django.conf import settings from django.core.signals import setting_changed -import warnings JSON_API_SETTINGS_PREFIX = 'JSON_API_' diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 5283c7bf..44221f3a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -351,7 +351,7 @@ def format_error_object(message, pointer, response): errors = [] if isinstance(message, dict): for k, v in message.items(): - errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) + errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) elif isinstance(message, list): for num, error in enumerate(message): if isinstance(error, (list, dict)): From 72df66fc58f9f4847539511196f2f27ddb7f429f Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Sun, 19 Apr 2020 03:02:41 +0300 Subject: [PATCH 15/18] requested changes --- example/tests/test_errors.py | 218 +++++++++++++++++++++++++ example/tests/test_nested_errors.py | 212 ------------------------ example/tests/unit/test_renderers.py | 91 +++++------ rest_framework_json_api/serializers.py | 22 +-- 4 files changed, 273 insertions(+), 270 deletions(-) create mode 100644 example/tests/test_errors.py delete mode 100644 example/tests/test_nested_errors.py diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py new file mode 100644 index 00000000..9e137774 --- /dev/null +++ b/example/tests/test_errors.py @@ -0,0 +1,218 @@ +import pytest +from django.conf.urls import url +from django.test import override_settings +from django.urls import reverse + +from example.models import Blog +from rest_framework_json_api import serializers +from rest_framework import views + + +# serializers +class CommentAttachmentSerializer(serializers.Serializer): + data = serializers.CharField(allow_null=False, required=True) + + def validate_data(self, value): + if value and len(value) < 10: + raise serializers.ValidationError('Too short data') + + +class CommentSerializer(serializers.Serializer): + attachments = CommentAttachmentSerializer(many=True, required=False) + attachment = CommentAttachmentSerializer(required=False) + body = serializers.CharField(allow_null=False, required=True) + + +class EntrySerializer(serializers.Serializer): + blog = serializers.IntegerField() + comments = CommentSerializer(many=True, required=False) + comment = CommentSerializer(required=False) + headline = serializers.CharField(allow_null=True, required=True) + body_text = serializers.CharField() + + def validate(self, attrs): + body_text = attrs['body_text'] + if len(body_text) < 5: + raise serializers.ValidationError({'body_text': 'Too short'}) + + +# view +class DummyTestView(views.APIView): + serializer_class = EntrySerializer + resource_name = 'entries' + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + +urlpatterns = [ + url(r'^entries-nested/$', DummyTestView.as_view(), + name='entries-nested-list') +] + + +@pytest.fixture(scope='function') +def some_blog(db): + return Blog.objects.create(name='Some Blog', tagline="It's a blog") + + +def perform_error_test(client, data, expected_pointer): + with override_settings( + JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True, + ROOT_URLCONF=__name__ + ): + url = reverse('entries-nested-list') + response = client.post(url, data=data) + + errors = response.data + + assert len(errors) == 1 + assert errors[0]['source']['pointer'] == expected_pointer + + +def test_first_level_attribute_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body_text', + } + } + } + perform_error_test(client, data, '/data/attributes/headline') + + +def test_first_level_custom_attribute_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body', + 'headline': 'headline' + } + } + } + with override_settings(JSON_API_FORMAT_FIELD_NAMES='underscore'): + perform_error_test(client, data, '/data/attributes/body_text') + + +def test_second_level_array_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + } + ] + } + } + } + + perform_error_test(client, data, '/data/attributes/comments/0/body') + + +def test_second_level_dict_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comment': {} + } + } + } + + perform_error_test(client, data, '/data/attributes/comment/body') + + +def test_third_level_array_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachments': [ + { + } + ] + } + ] + } + } + } + + perform_error_test(client, data, '/data/attributes/comments/0/attachments/0/data') + + +def test_third_level_custom_array_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachments': [ + { + 'data': 'text' + } + ] + } + ] + } + } + } + + perform_error_test(client, data, '/data/attributes/comments/0/attachments/0/data') + + +def test_third_level_dict_error(client, some_blog): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body_text': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachment': {} + } + ] + } + } + } + + perform_error_test(client, data, '/data/attributes/comments/0/attachment/data') + + +@pytest.mark.filterwarning('default::DeprecationWarning:rest_framework_json_api.serializers') +def test_deprecation_warning(recwarn): + class DummyNestedSerializer(serializers.Serializer): + field = serializers.CharField() + + class DummySerializer(serializers.Serializer): + nested = DummyNestedSerializer(many=True) + + assert len(recwarn) == 1 + warning = recwarn.pop(DeprecationWarning) + assert warning + assert str(warning.message).startswith('Rendering') diff --git a/example/tests/test_nested_errors.py b/example/tests/test_nested_errors.py deleted file mode 100644 index 37502e4d..00000000 --- a/example/tests/test_nested_errors.py +++ /dev/null @@ -1,212 +0,0 @@ -import pytest -from django.conf.urls import url -from django.test import override_settings -from django.urls import reverse - -from example.models import Blog -from example.tests import TestBase -from rest_framework_json_api import serializers -from rest_framework import views - - -# serializers -class CommentAttachmentSerializer(serializers.Serializer): - data = serializers.CharField(allow_null=False, required=True) - - def validate_data(self, value): - if value and len(value) < 10: - raise serializers.ValidationError('Too short data') - - -class CommentSerializer(serializers.Serializer): - attachments = CommentAttachmentSerializer(many=True, required=False) - attachment = CommentAttachmentSerializer(required=False) - body = serializers.CharField(allow_null=False, required=True) - - -class EntrySerializer(serializers.Serializer): - blog = serializers.IntegerField() - comments = CommentSerializer(many=True, required=False) - comment = CommentSerializer(required=False) - headline = serializers.CharField(allow_null=True, required=True) - body_text = serializers.CharField() - - def validate(self, attrs): - body_text = attrs['body_text'] - if len(body_text) < 5: - raise serializers.ValidationError({'body_text': 'Too short'}) - - -# view -class DummyTestView(views.APIView): - serializer_class = EntrySerializer - resource_name = 'entries' - - def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - - -urlpatterns = [ - url(r'^entries-nested/$', DummyTestView.as_view(), - name='entries-nested-list') -] - - -@override_settings(ROOT_URLCONF=__name__) -class TestNestedErrors(TestBase): - - def setUp(self): - super(TestNestedErrors, self).setUp() - self.url = reverse('entries-nested-list') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") - - def perform_error_test(self, data, expected_pointer): - with override_settings( - JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): - response = self.client.post(self.url, data=data) - - errors = response.data - - assert len(errors) == 1 - assert errors[0]['source']['pointer'] == expected_pointer - - def test_first_level_attribute_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body_text', - } - } - } - self.perform_error_test(data, '/data/attributes/headline') - - def test_first_level_custom_attribute_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body', - 'headline': 'headline' - } - } - } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='underscore'): - self.perform_error_test(data, '/data/attributes/body_text') - - def test_second_level_array_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - } - ] - } - } - } - - self.perform_error_test(data, '/data/attributes/comments/0/body') - - def test_second_level_dict_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comment': {} - } - } - } - - self.perform_error_test(data, '/data/attributes/comment/body') - - def test_third_level_array_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - } - ] - } - ] - } - } - } - - self.perform_error_test(data, '/data/attributes/comments/0/attachments/0/data') - - def test_third_level_custom_array_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - 'data': 'text' - } - ] - } - ] - } - } - } - - self.perform_error_test(data, '/data/attributes/comments/0/attachments/0/data') - - def test_third_level_dict_error(self): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': self.blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachment': {} - } - ] - } - } - } - - self.perform_error_test(data, '/data/attributes/comments/0/attachment/data') - - -@pytest.mark.filterwarning('default::DeprecationWarning:rest_framework_json_api.serializers') -def test_deprecation_warning(recwarn): - class DummyNestedSerializer(serializers.Serializer): - field = serializers.CharField() - - class DummySerializer(serializers.Serializer): - nested = DummyNestedSerializer(many=True) - - assert len(recwarn) == 1 - warning = recwarn.pop(DeprecationWarning) - assert warning - assert str(warning.message).startswith('Rendering') diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 174cbe63..d7a11969 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -174,55 +174,52 @@ def test_extract_relation_instance(comment): assert got == comment.entry.blog -class TestRenderingStrategy(TestBase): - - def setUp(self): - super(TestRenderingStrategy, 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 - ) +def test_attribute_rendering_strategy(db): + # setting up + blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + entry = Entry.objects.create( + blog=blog, + headline='headline', + body_text='body_text', + pub_date=timezone.now(), + mod_date=timezone.now(), + n_comments=0, + n_pingbacks=0, + rating=3 + ) - self.author = Author.objects.create(name='some_author', email='some_author@example.org') - self.entry.authors.add(self.author) + author = Author.objects.create(name='some_author', email='some_author@example.org') + entry.authors.add(author) - self.comment = Comment.objects.create( - entry=self.entry, - body='testing one two three', - author=Author.objects.first() - ) + Comment.objects.create( + entry=entry, + body='testing one two three', + author=Author.objects.first() + ) - def test_attribute_rendering_strategy(self): - with override_settings( - JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): - rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, self.author) - result = json.loads(rendered.decode()) - - expected = { - "data": { - "type": "authors", - "id": "1", - "attributes": { - "name": "some_author", - "email": "some_author@example.org", - "comments": [ - { - "id": 1, - "entry": { - 'headline': 'headline', - 'body_text': 'body_text', - }, - "body": "testing one two three" - } - ] - } + with override_settings( + JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): + rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) + result = json.loads(rendered.decode()) + + expected = { + "data": { + "type": "authors", + "id": "1", + "attributes": { + "name": "some_author", + "email": "some_author@example.org", + "comments": [ + { + "id": 1, + "entry": { + 'headline': 'headline', + 'body_text': 'body_text', + }, + "body": "testing one two three" + } + ] } } - self.assertDictEqual(expected, result) + } + assert expected == result diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 154d7aed..4de984f9 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -124,11 +124,9 @@ class SerializerMetaclass(SerializerMetaclass): @classmethod def _get_declared_fields(cls, bases, attrs): fields = super()._get_declared_fields(bases, attrs) - setting_name = 'JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE' for field_name, field in fields.items(): if isinstance(field, BaseSerializer) and \ - not json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE and \ - not hasattr(json_api_settings.user_settings, setting_name): + not json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE: clazz = '{}.{}'.format(attrs['__module__'], attrs['__qualname__']) if isinstance(field, ListSerializer): nested_class = type(field.child).__name__ @@ -136,18 +134,20 @@ def _get_declared_fields(cls, bases, attrs): nested_class = type(field).__name__ warnings.warn(DeprecationWarning( - "Rendering nested serializers in relations by default is deprecated and will " - "be changed in future releases. Please, use ResourceRelatedField instead of " - "{} in serializer {} or set JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE" - " to False".format(nested_class, clazz))) + "Rendering nested serializer as relationship is deprecated. " + "Use `ResourceRelatedField` instead if {} in serializer {} should remain " + "a relationship. Otherwise set " + "JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested " + "serializer as nested json attribute".format(nested_class, clazz))) return fields # If user imports serializer from here we can catch class definition and check -# nested serializers for depricated use. Probably it is not bad idea to add -# sparse and included mixins to this definition, in case people want to use -# DRF-JA without models underlying. -class Serializer(Serializer, metaclass=SerializerMetaclass): +# nested serializers for depricated use. +class Serializer( + IncludedResourcesValidationMixin, SparseFieldsetsMixin, Serializer, + metaclass=SerializerMetaclass +): pass From 873b48e5d238727559dd9faca68bff6016f5b09c Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Sun, 19 Apr 2020 03:19:00 +0300 Subject: [PATCH 16/18] f unused import --- example/tests/unit/test_renderers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index d7a11969..7aa805ee 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -4,7 +4,6 @@ from django.test import override_settings from django.utils import timezone -from example.tests import TestBase from rest_framework_json_api import serializers, views from rest_framework_json_api.renderers import JSONRenderer From f5f68d4e963aa41f7ad3eed0af7763d5e45325de Mon Sep 17 00:00:00 2001 From: Boris Pleshakov Date: Wed, 13 May 2020 17:30:24 +0300 Subject: [PATCH 17/18] f remove error handling part --- example/tests/test_errors.py | 218 ------------------------------- rest_framework_json_api/utils.py | 55 +++----- 2 files changed, 18 insertions(+), 255 deletions(-) delete mode 100644 example/tests/test_errors.py diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py deleted file mode 100644 index 9e137774..00000000 --- a/example/tests/test_errors.py +++ /dev/null @@ -1,218 +0,0 @@ -import pytest -from django.conf.urls import url -from django.test import override_settings -from django.urls import reverse - -from example.models import Blog -from rest_framework_json_api import serializers -from rest_framework import views - - -# serializers -class CommentAttachmentSerializer(serializers.Serializer): - data = serializers.CharField(allow_null=False, required=True) - - def validate_data(self, value): - if value and len(value) < 10: - raise serializers.ValidationError('Too short data') - - -class CommentSerializer(serializers.Serializer): - attachments = CommentAttachmentSerializer(many=True, required=False) - attachment = CommentAttachmentSerializer(required=False) - body = serializers.CharField(allow_null=False, required=True) - - -class EntrySerializer(serializers.Serializer): - blog = serializers.IntegerField() - comments = CommentSerializer(many=True, required=False) - comment = CommentSerializer(required=False) - headline = serializers.CharField(allow_null=True, required=True) - body_text = serializers.CharField() - - def validate(self, attrs): - body_text = attrs['body_text'] - if len(body_text) < 5: - raise serializers.ValidationError({'body_text': 'Too short'}) - - -# view -class DummyTestView(views.APIView): - serializer_class = EntrySerializer - resource_name = 'entries' - - def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - - -urlpatterns = [ - url(r'^entries-nested/$', DummyTestView.as_view(), - name='entries-nested-list') -] - - -@pytest.fixture(scope='function') -def some_blog(db): - return Blog.objects.create(name='Some Blog', tagline="It's a blog") - - -def perform_error_test(client, data, expected_pointer): - with override_settings( - JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True, - ROOT_URLCONF=__name__ - ): - url = reverse('entries-nested-list') - response = client.post(url, data=data) - - errors = response.data - - assert len(errors) == 1 - assert errors[0]['source']['pointer'] == expected_pointer - - -def test_first_level_attribute_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body_text', - } - } - } - perform_error_test(client, data, '/data/attributes/headline') - - -def test_first_level_custom_attribute_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body', - 'headline': 'headline' - } - } - } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='underscore'): - perform_error_test(client, data, '/data/attributes/body_text') - - -def test_second_level_array_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - } - ] - } - } - } - - perform_error_test(client, data, '/data/attributes/comments/0/body') - - -def test_second_level_dict_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comment': {} - } - } - } - - perform_error_test(client, data, '/data/attributes/comment/body') - - -def test_third_level_array_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - } - ] - } - ] - } - } - } - - perform_error_test(client, data, '/data/attributes/comments/0/attachments/0/data') - - -def test_third_level_custom_array_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - 'data': 'text' - } - ] - } - ] - } - } - } - - perform_error_test(client, data, '/data/attributes/comments/0/attachments/0/data') - - -def test_third_level_dict_error(client, some_blog): - data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body_text': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachment': {} - } - ] - } - } - } - - perform_error_test(client, data, '/data/attributes/comments/0/attachment/data') - - -@pytest.mark.filterwarning('default::DeprecationWarning:rest_framework_json_api.serializers') -def test_deprecation_warning(recwarn): - class DummyNestedSerializer(serializers.Serializer): - field = serializers.CharField() - - class DummySerializer(serializers.Serializer): - nested = DummyNestedSerializer(many=True) - - assert len(recwarn) == 1 - warning = recwarn.pop(DeprecationWarning) - assert warning - assert str(warning.message).startswith('Rendering') diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 44221f3a..b3932651 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -312,34 +312,29 @@ def format_drf_errors(response, context, exc): # handle generic errors. ValidationError('test') in a view for example if isinstance(response.data, list): for message in response.data: - errors.extend(format_error_object(message, '/data', response)) + errors.append(format_error_object(message, '/data', response)) # handle all errors thrown from serializers else: for field, error in response.data.items(): field = format_value(field) pointer = '/data/attributes/{}'.format(field) # see if they passed a dictionary to ValidationError manually - # The bit tricky problem is here. It is may be nested drf thing in format - # name: error_object, or it may be custom error thrown by user. I guess, - # if it is drf error, dict will always have single key if isinstance(error, dict): - if len(error) > 1: - errors.append(error) - else: - errors.extend(format_error_object(error, pointer, response)) + errors.append(error) elif isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer - errors.extend(format_error_object(error, None, response)) + errors.append(format_error_object(error, None, response)) elif isinstance(error, str): classes = inspect.getmembers(exceptions, inspect.isclass) # DRF sets the `field` to 'detail' for its own exceptions if isinstance(exc, tuple(x[1] for x in classes)): pointer = '/data' - errors.extend(format_error_object(error, pointer, response)) + errors.append(format_error_object(error, pointer, response)) elif isinstance(error, list): - errors.extend(format_error_object(error, pointer, response)) + for message in error: + errors.append(format_error_object(message, pointer, response)) else: - errors.extend(format_error_object(error, pointer, response)) + errors.append(format_error_object(error, pointer, response)) context['view'].resource_name = 'errors' response.data = errors @@ -348,32 +343,18 @@ def format_drf_errors(response, context, exc): def format_error_object(message, pointer, response): - errors = [] - if isinstance(message, dict): - for k, v in message.items(): - errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) - elif isinstance(message, list): - for num, error in enumerate(message): - if isinstance(error, (list, dict)): - new_pointer = pointer + '/{}'.format(num) - else: - new_pointer = pointer - if error: - errors.extend(format_error_object(error, new_pointer, response)) - else: - error_obj = { - 'detail': message, - 'status': encoding.force_str(response.status_code), + error_obj = { + 'detail': message, + 'status': encoding.force_str(response.status_code), + } + if pointer is not None: + error_obj['source'] = { + 'pointer': pointer, } - if pointer is not None: - error_obj['source'] = { - 'pointer': pointer, - } - code = getattr(message, "code", None) - if code is not None: - error_obj['code'] = code - errors.append(error_obj) - return errors + code = getattr(message, "code", None) + if code is not None: + error_obj['code'] = code + return error_obj def format_errors(data): From deec195df32a86b83d5298afcdadb1038dd787a2 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 14 May 2020 09:26:32 +0200 Subject: [PATCH 18/18] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bffa29f..fd4e2096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [3.2.0] - pending +## [Unreleased] ### Added -* Added support for serializiing complex structures as attributes. For details please reffer to #769 +* Added support for serializing nested serializers as attribute json value introducing setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` + +### Fixed + * Avoid `AttributeError` for PUT and PATCH methods when using `APIView` ### Changed @@ -23,6 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Deprecated * Deprecate `source` argument of `SerializerMethodResourceRelatedField`, use `method_name` instead +* Rendering nested serializers as relationships is deprecated. Use `ResourceRelatedField` instead ## [3.1.0] - 2020-02-08