From 5292c5a73ae4a852e31c9573a1df58f813db78ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20S?= Date: Mon, 16 May 2016 00:26:48 +0200 Subject: [PATCH 1/5] Fix example migration and tests Update gitignore --- .gitignore | 2 ++ example/migrations/0002_auto_20160513_0857.py | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 3177afc7..fe958047 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ pip-delete-this-directory.txt # Tox .tox/ +.cache/ +.python-version # VirtualEnv .venv/ diff --git a/example/migrations/0002_auto_20160513_0857.py b/example/migrations/0002_auto_20160513_0857.py index 4ed9803b..2471ea36 100644 --- a/example/migrations/0002_auto_20160513_0857.py +++ b/example/migrations/0002_auto_20160513_0857.py @@ -1,17 +1,26 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.6 on 2016-05-13 08:57 from __future__ import unicode_literals +from distutils.version import LooseVersion from django.db import migrations, models import django.db.models.deletion +import django class Migration(migrations.Migration): - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('example', '0001_initial'), - ] + # TODO: Must be removed as soon as Django 1.7 support is dropped + if django.get_version() < LooseVersion('1.8'): + dependencies = [ + ('contenttypes', '0001_initial'), + ('example', '0001_initial'), + ] + else: + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('example', '0001_initial'), + ] operations = [ migrations.CreateModel( From 2955fcfd6ad292275574822f7924a9f5d45b9d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20S?= Date: Mon, 16 May 2016 15:44:38 +0200 Subject: [PATCH 2/5] Polymorphic serializers refactor --- example/serializers.py | 58 ++----- .../tests/integration/test_polymorphism.py | 6 +- rest_framework_json_api/serializers.py | 145 +++++++++++++++++- rest_framework_json_api/utils.py | 8 +- 4 files changed, 156 insertions(+), 61 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index a774959e..a792ff40 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -1,7 +1,5 @@ from datetime import datetime -from django.db.models.query import QuerySet -from rest_framework.utils.serializer_helpers import BindingDict -from rest_framework_json_api import serializers, relations, utils +from rest_framework_json_api import serializers, relations from example import models @@ -40,15 +38,15 @@ def __init__(self, *args, **kwargs): } body_format = serializers.SerializerMethodField() - # many related from model + # Many related from model comments = relations.ResourceRelatedField( - source='comment_set', many=True, read_only=True) - # many related from serializer + source='comment_set', many=True, read_only=True) + # Many related from serializer suggested = relations.SerializerMethodResourceRelatedField( - source='get_suggested', model=models.Entry, many=True, read_only=True) - # single related from serializer + source='get_suggested', model=models.Entry, many=True, read_only=True) + # Single related from serializer featured = relations.SerializerMethodResourceRelatedField( - source='get_featured', model=models.Entry, read_only=True) + source='get_featured', model=models.Entry, read_only=True) def get_suggested(self, obj): return models.Entry.objects.exclude(pk=obj.pk) @@ -107,51 +105,13 @@ class Meta: exclude = ('polymorphic_ctype',) -class ProjectSerializer(serializers.ModelSerializer): - - polymorphic_serializers = [ - {'model': models.ArtProject, 'serializer': ArtProjectSerializer}, - {'model': models.ResearchProject, 'serializer': ResearchProjectSerializer}, - ] +class ProjectSerializer(serializers.PolymorphicModelSerializer): + polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer] class Meta: model = models.Project exclude = ('polymorphic_ctype',) - def _get_actual_serializer_from_instance(self, instance): - for info in self.polymorphic_serializers: - if isinstance(instance, info.get('model')): - actual_serializer = info.get('serializer') - return actual_serializer(instance, context=self.context) - - @property - def fields(self): - _fields = BindingDict(self) - for key, value in self.get_fields().items(): - _fields[key] = value - return _fields - - def get_fields(self): - if self.instance is not None: - if not isinstance(self.instance, QuerySet): - return self._get_actual_serializer_from_instance(self.instance).get_fields() - else: - raise Exception("Cannot get fields from a polymorphic serializer given a queryset") - return super(ProjectSerializer, self).get_fields() - - def to_representation(self, instance): - # Handle polymorphism - return self._get_actual_serializer_from_instance(instance).to_representation(instance) - - def to_internal_value(self, data): - data_type = data.get('type') - for info in self.polymorphic_serializers: - actual_serializer = info['serializer'] - if data_type == utils.get_resource_type_from_serializer(actual_serializer): - self.__class__ = actual_serializer - return actual_serializer(data, context=self.context).to_internal_value(data) - raise Exception("Could not deserialize") - class CompanySerializer(serializers.ModelSerializer): included_serializers = { diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 9fd74d13..514dc31f 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -29,13 +29,14 @@ def test_polymorphism_on_included_relations(single_company, client): assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" assert [rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]] == [ "researchProjects", "artProjects"] - assert [x.get('type') for x in content.get('included')] == ['artProjects', 'artProjects', 'researchProjects'], \ - 'Detail included types are incorrect' + assert [x.get('type') for x in content.get('included')] == [ + 'artProjects', 'artProjects', 'researchProjects'], 'Detail included types are incorrect' # Ensure that the child fields are present. assert content.get('included')[0].get('attributes').get('artist') is not None assert content.get('included')[1].get('attributes').get('artist') is not None assert content.get('included')[2].get('attributes').get('supervisor') is not None + def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, client): url = reverse("project-detail", kwargs={'pk': single_art_project.pk}) response = client.get(url) @@ -50,6 +51,7 @@ def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, clie assert new_content['data']['attributes']['topic'] == test_topic assert new_content['data']['attributes']['artist'] == test_artist + def test_polymorphism_on_polymorphic_model_list_post(client): test_topic = 'New test topic {}'.format(random.randint(0, 999999)) test_artist = 'test-{}'.format(random.randint(0, 999999)) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 953c4437..e95863c1 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,8 +1,11 @@ +from django.db.models.query import QuerySet from django.utils.translation import ugettext_lazy as _ +from django.utils import six from rest_framework.exceptions import ParseError from rest_framework.serializers import * from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.utils import ( get_resource_type_from_model, get_resource_type_from_instance, get_resource_type_from_serializer, get_included_serializers) @@ -10,7 +13,8 @@ class ResourceIdentifierObjectSerializer(BaseSerializer): default_error_messages = { - 'incorrect_model_type': _('Incorrect model type. Expected {model_type}, received {received_type}.'), + 'incorrect_model_type': _('Incorrect model type. Expected {model_type}, ' + 'received {received_type}.'), 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), 'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'), } @@ -20,7 +24,8 @@ class ResourceIdentifierObjectSerializer(BaseSerializer): def __init__(self, *args, **kwargs): self.model_class = kwargs.pop('model_class', self.model_class) if 'instance' not in kwargs and not self.model_class: - raise RuntimeError('ResourceIdentifierObjectsSerializer must be initialized with a model class.') + raise RuntimeError( + 'ResourceIdentifierObjectsSerializer must be initialized with a model class.') super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) def to_representation(self, instance): @@ -31,7 +36,8 @@ def to_representation(self, instance): def to_internal_value(self, data): if data['type'] != get_resource_type_from_model(self.model_class): - self.fail('incorrect_model_type', model_type=self.model_class, received_type=data['type']) + self.fail( + 'incorrect_model_type', model_type=self.model_class, received_type=data['type']) pk = data['id'] try: return self.model_class.objects.get(pk=pk) @@ -47,15 +53,18 @@ def __init__(self, *args, **kwargs): request = context.get('request') if context else None if request: - sparse_fieldset_query_param = 'fields[{}]'.format(get_resource_type_from_serializer(self)) + sparse_fieldset_query_param = 'fields[{}]'.format( + get_resource_type_from_serializer(self)) try: - param_name = next(key for key in request.query_params if sparse_fieldset_query_param in key) + param_name = next( + key for key in request.query_params if sparse_fieldset_query_param in key) except StopIteration: pass else: fieldset = request.query_params.get(param_name).split(',') - # iterate over a *copy* of self.fields' underlying OrderedDict, because we may modify the - # original during the iteration. self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` + # Iterate over a *copy* of self.fields' underlying OrderedDict, because we may + # modify the original during the iteration. + # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` for field_name, field in self.fields.fields.copy().items(): if field_name == api_settings.URL_FIELD_NAME: # leave self link there continue @@ -101,7 +110,8 @@ def validate_path(serializer_class, field_path, path): super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) -class HyperlinkedModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer): +class HyperlinkedModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, + HyperlinkedModelSerializer): """ A type of `ModelSerializer` that uses hyperlinked relationships instead of primary key relationships. Specifically: @@ -152,3 +162,122 @@ def get_field_names(self, declared_fields, info): declared[field_name] = field fields = super(ModelSerializer, self).get_field_names(declared, info) return list(fields) + list(getattr(self.Meta, 'meta_fields', list())) + + +class PolymorphicSerializerMetaclass(SerializerMetaclass): + """ + This metaclass ensures that the `polymorphic_serializers` is correctly defined on a + `PolymorphicSerializer` class and make a cache of model/serializer/type mappings. + """ + + def __new__(cls, name, bases, attrs): + new_class = super(PolymorphicSerializerMetaclass, cls).__new__(cls, name, bases, attrs) + + # Ensure initialization is only performed for subclasses of PolymorphicModelSerializer + # (excluding PolymorphicModelSerializer class itself). + parents = [b for b in bases if isinstance(b, PolymorphicSerializerMetaclass)] + if not parents: + return new_class + + polymorphic_serializers = getattr(new_class, 'polymorphic_serializers', None) + if not polymorphic_serializers: + raise NotImplementedError( + "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute.") + serializer_to_model = { + serializer: serializer.Meta.model for serializer in polymorphic_serializers} + model_to_serializer = { + serializer.Meta.model: serializer for serializer in polymorphic_serializers} + type_to_model = { + get_resource_type_from_model(model): model for model in model_to_serializer.keys()} + setattr(new_class, '_poly_serializer_model_map', serializer_to_model) + setattr(new_class, '_poly_model_serializer_map', model_to_serializer) + setattr(new_class, '_poly_type_model_map', type_to_model) + return new_class + + +@six.add_metaclass(PolymorphicSerializerMetaclass) +class PolymorphicModelSerializer(ModelSerializer): + """ + A serializer for polymorphic models. + Useful for "lazy" parent models. Leaves should be represented with a regular serializer. + """ + def get_fields(self): + """ + Return an exhaustive list of the polymorphic serializer fields. + """ + if self.instance is not None: + if not isinstance(self.instance, QuerySet): + serializer_class = self.get_polymorphic_serializer_for_instance(self.instance) + return serializer_class(self.instance, context=self.context).get_fields() + else: + raise Exception("Cannot get fields from a polymorphic serializer given a queryset") + return super(PolymorphicModelSerializer, self).get_fields() + + def get_polymorphic_serializer_for_instance(self, instance): + """ + Return the polymorphic serializer associated with the given instance/model. + Raise `NotImplementedError` if no serializer is found for the given model. This usually + means that a serializer is missing in the class's `polymorphic_serializers` attribute. + """ + try: + return self._poly_model_serializer_map[instance._meta.model] + except KeyError: + raise NotImplementedError( + "No polymorphic serializer has been found for model {}".format( + instance._meta.model.__name__)) + + def get_polymorphic_model_for_serializer(self, serializer): + """ + Return the polymorphic model associated with the given serializer. + Raise `NotImplementedError` if no model is found for the given serializer. This usually + means that a serializer is missing in the class's `polymorphic_serializers` attribute. + """ + try: + return self._poly_serializer_model_map[serializer] + except KeyError: + raise NotImplementedError( + "No polymorphic model has been found for serializer {}".format(serializer.__name__)) + + def get_polymorphic_model_for_type(self, obj_type): + """ + Return the polymorphic model associated with the given type. + Raise `NotImplementedError` if no model is found for the given type. This usually + means that a serializer is missing in the class's `polymorphic_serializers` attribute. + """ + try: + return self._poly_type_model_map[obj_type] + except KeyError: + raise NotImplementedError( + "No polymorphic model has been found for type {}".format(obj_type)) + + def get_polymorphic_serializer_for_type(self, obj_type): + """ + Return the polymorphic serializer associated with the given type. + Raise `NotImplementedError` if no serializer is found for the given type. This usually + means that a serializer is missing in the class's `polymorphic_serializers` attribute. + """ + return self.get_polymorphic_serializer_for_instance( + self.get_polymorphic_model_for_type(obj_type)) + + def to_representation(self, instance): + """ + Retrieve the appropriate polymorphic serializer and use this to handle representation. + """ + serializer_class = self.get_polymorphic_serializer_for_instance(instance) + return serializer_class(instance, context=self.context).to_representation(instance) + + def to_internal_value(self, data): + """ + Ensure that the given type is one of the expected polymorphic types, then retrieve the + appropriate polymorphic serializer and use this to handle internal value. + """ + received_type = data.get('type') + expected_types = self._poly_type_model_map.keys() + if received_type not in expected_types: + raise Conflict( + 'Incorrect relation type. Expected on of {expected_types}, ' + 'received {received_type}.'.format( + expected_types=', '.join(expected_types), received_type=received_type)) + serializer_class = self.get_polymorphic_serializer_for_type(received_type) + self.__class__ = serializer_class + return serializer_class(data, context=self.context).to_internal_value(data) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 19c3629c..03762ef7 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -91,6 +91,7 @@ def get_serializer_fields(serializer): pass return fields + def format_keys(obj, format_type=None): """ Takes either a dict or list and returns it with camelized keys only if @@ -146,12 +147,15 @@ def format_value(value, format_type=None): def format_relation_name(value, format_type=None): - warnings.warn("The 'format_relation_name' function has been renamed 'format_resource_type' and the settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES'") + warnings.warn( + "The 'format_relation_name' function has been renamed 'format_resource_type' and " + "the settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES'") if format_type is None: format_type = getattr(settings, 'JSON_API_FORMAT_RELATION_KEYS', None) pluralize = getattr(settings, 'JSON_API_PLURALIZE_RELATION_TYPE', None) return format_resource_type(value, format_type, pluralize) + def format_resource_type(value, format_type=None, pluralize=None): if format_type is None: format_type = getattr(settings, 'JSON_API_FORMAT_TYPES', False) @@ -232,7 +236,7 @@ def get_resource_type_from_manager(manager): def get_resource_type_from_serializer(serializer): if hasattr(serializer, 'polymorphic_serializers'): - return [get_resource_type_from_serializer(s['serializer']) for s in serializer.polymorphic_serializers] + return [get_resource_type_from_serializer(s) for s in serializer.polymorphic_serializers] elif hasattr(serializer.Meta, 'resource_name'): return serializer.Meta.resource_name else: From c96383914c40b71bc8bdbc2d03d17dea7f797e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20S?= Date: Mon, 16 May 2016 17:14:32 +0200 Subject: [PATCH 3/5] Basic support of write operations on polymorphic relations --- example/serializers.py | 3 ++ .../tests/integration/test_polymorphism.py | 18 ++++++++ rest_framework_json_api/relations.py | 44 ++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index a792ff40..cf5ece4b 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -114,6 +114,9 @@ class Meta: class CompanySerializer(serializers.ModelSerializer): + current_project = relations.PolymorphicResourceRelatedField(ProjectSerializer, queryset=models.Project.objects.all()) + future_projects = relations.PolymorphicResourceRelatedField(ProjectSerializer, queryset=models.Project.objects.all(), many=True) + included_serializers = { 'current_project': ProjectSerializer, 'future_projects': ProjectSerializer, diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 514dc31f..a4e02016 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -71,3 +71,21 @@ def test_polymorphism_on_polymorphic_model_list_post(client): assert content["data"]["type"] == "artProjects" assert content['data']['attributes']['topic'] == test_topic assert content['data']['attributes']['artist'] == test_artist + + +def test_polymorphism_relations_update(single_company, research_project_factory, client): + response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) + content = load_json(response.content) + assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + + research_project = research_project_factory() + content["data"]["relationships"]["currentProject"]["data"] = { + "type": "researchProjects", + "id": research_project.pk + } + response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}), + data=json.dumps(content), content_type='application/vnd.api+json') + assert response.status_code is 200 + content = load_json(response.content) + assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "researchProjects" + assert int(content["data"]["relationships"]["currentProject"]["data"]["id"]) is research_project.pk diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index e45d09cd..ab8754fc 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -12,6 +12,7 @@ class ResourceRelatedField(PrimaryKeyRelatedField): + _skip_polymorphic_optimization = True self_link_view_name = None related_link_view_name = None related_link_lookup_field = 'pk' @@ -21,6 +22,7 @@ class ResourceRelatedField(PrimaryKeyRelatedField): 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), 'incorrect_type': _('Incorrect type. Expected resource identifier object, received {data_type}.'), 'incorrect_relation_type': _('Incorrect relation type. Expected {relation_type}, received {received_type}.'), + # 'incorrect_poly_relation_type': _('Incorrect relation type. Expected one of {relation_type}, received {received_type}.'), 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), 'no_match': _('Invalid hyperlink - No URL match.'), @@ -135,7 +137,8 @@ def to_internal_value(self, data): self.fail('missing_id') if data['type'] != expected_relation_type: - self.conflict('incorrect_relation_type', relation_type=expected_relation_type, received_type=data['type']) + self.conflict('incorrect_relation_type', relation_type=expected_relation_type, + received_type=data['type']) return super(ResourceRelatedField, self).to_internal_value(data['id']) @@ -150,7 +153,8 @@ def to_representation(self, value): resource_type = None root = getattr(self.parent, 'parent', self.parent) field_name = self.field_name if self.field_name else self.parent.field_name - if getattr(root, 'included_serializers', None) is not None and not self.is_polymorphic: + if getattr(root, 'included_serializers', None) is not None and \ + self._skip_polymorphic_optimization: includes = get_included_serializers(root) if field_name in includes.keys(): resource_type = get_resource_type_from_serializer(includes[field_name]) @@ -175,6 +179,42 @@ def choices(self): ]) +class PolymorphicResourceRelatedField(ResourceRelatedField): + + _skip_polymorphic_optimization = False + default_error_messages = dict(ResourceRelatedField.default_error_messages, **{ + 'incorrect_relation_type': _('Incorrect relation type. Expected one of {relation_type}, ' + 'received {received_type}.'), + }) + + def __init__(self, polymorphic_serializer, *args, **kwargs): + self.polymorphic_serializer = polymorphic_serializer + super(PolymorphicResourceRelatedField, self).__init__(*args, **kwargs) + + def to_internal_value(self, data): + if isinstance(data, six.text_type): + try: + data = json.loads(data) + except ValueError: + # show a useful error if they send a `pk` instead of resource object + self.fail('incorrect_type', data_type=type(data).__name__) + if not isinstance(data, dict): + self.fail('incorrect_type', data_type=type(data).__name__) + + if 'type' not in data: + self.fail('missing_type') + + if 'id' not in data: + self.fail('missing_id') + + expected_relation_types = get_resource_type_from_serializer(self.polymorphic_serializer) + + if data['type'] not in expected_relation_types: + self.conflict('incorrect_relation_type', relation_type=", ".join( + expected_relation_types), received_type=data['type']) + + return super(ResourceRelatedField, self).to_internal_value(data['id']) + class SerializerMethodResourceRelatedField(ResourceRelatedField): """ From 9bd36f12b7f5bde9fc5eb17c3388b59d1558bbf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20S?= Date: Tue, 17 May 2016 16:19:21 +0200 Subject: [PATCH 4/5] Improve polymorphism documentation --- docs/usage.md | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 5ded0164..89dcd363 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -375,7 +375,7 @@ class LineItemViewSet(viewsets.ModelViewSet): ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build -relationship views (see the +relationship views (see the [JSON API spec](http://jsonapi.org/format/#fetching-relationships)). The `self` link on a relationship object should point to the corresponding relationship view. @@ -425,7 +425,9 @@ field_name_mapping = { ### Working with polymorphic resources -This package can defer the resolution of the type of polymorphic models instances to get the appropriate type. +#### Extraction of the polymorphic type + +This package can defer the resolution of the type of polymorphic models instances to retrieve the appropriate type. However, most models are not polymorphic and for performance reasons this is only done if the underlying model is a subclass of a polymorphic model. Polymorphic ancestors must be defined on settings like this: @@ -436,6 +438,40 @@ JSON_API_POLYMORPHIC_ANCESTORS = ( ) ``` +#### Writing polymorphic resources + +A polymorphic endpoint can be setup if associated with a polymorphic serializer. +A polymorphic serializer take care of (de)serializing the correct instances types and can be defined like this: + +```python +class ProjectSerializer(serializers.PolymorphicModelSerializer): + polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer] + + class Meta: + model = models.Project +``` + +It must inherit from `serializers.PolymorphicModelSerializer` and define the `polymorphic_serializers` list. +This attribute defines the accepted resource types. + + +Polymorphic relations can also be handled with `relations.PolymorphicResourceRelatedField` like this: + +```python +class CompanySerializer(serializers.ModelSerializer): + current_project = relations.PolymorphicResourceRelatedField( + ProjectSerializer, queryset=models.Project.objects.all()) + future_projects = relations.PolymorphicResourceRelatedField( + ProjectSerializer, queryset=models.Project.objects.all(), many=True) + + class Meta: + model = models.Company +``` + +They must be explicitely declared with the `polymorphic_serializer` (first positional argument) correctly defined. +It must be a subclass of `serializers.PolymorphicModelSerializer`. + + ### Meta You may add metadata to the rendered json in two different ways: `meta_fields` and `get_root_meta`. From fe7b63a026d2e7610f960eeb7673280499634821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20S?= Date: Tue, 17 May 2016 18:02:25 +0200 Subject: [PATCH 5/5] Improve polymorphic relations and tests. --- docs/usage.md | 8 +++ example/serializers.py | 6 ++- .../tests/integration/test_polymorphism.py | 49 ++++++++++++++++- rest_framework_json_api/parsers.py | 52 +++++++++++-------- rest_framework_json_api/relations.py | 5 +- rest_framework_json_api/serializers.py | 50 +++++++++++------- rest_framework_json_api/utils.py | 13 +++-- 7 files changed, 131 insertions(+), 52 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 89dcd363..4a919fc1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -471,6 +471,14 @@ class CompanySerializer(serializers.ModelSerializer): They must be explicitely declared with the `polymorphic_serializer` (first positional argument) correctly defined. It must be a subclass of `serializers.PolymorphicModelSerializer`. +
+ Note: + Polymorphic resources are not compatible with + + resource_name + + defined on the view. +
### Meta diff --git a/example/serializers.py b/example/serializers.py index cf5ece4b..09119d1c 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -114,8 +114,10 @@ class Meta: class CompanySerializer(serializers.ModelSerializer): - current_project = relations.PolymorphicResourceRelatedField(ProjectSerializer, queryset=models.Project.objects.all()) - future_projects = relations.PolymorphicResourceRelatedField(ProjectSerializer, queryset=models.Project.objects.all(), many=True) + current_project = relations.PolymorphicResourceRelatedField( + ProjectSerializer, queryset=models.Project.objects.all()) + future_projects = relations.PolymorphicResourceRelatedField( + ProjectSerializer, queryset=models.Project.objects.all(), many=True) included_serializers = { 'current_project': ProjectSerializer, diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index a4e02016..5b7fbb7b 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -73,6 +73,29 @@ def test_polymorphism_on_polymorphic_model_list_post(client): assert content['data']['attributes']['artist'] == test_artist +def test_invalid_type_on_polymorphic_model(client): + test_topic = 'New test topic {}'.format(random.randint(0, 999999)) + test_artist = 'test-{}'.format(random.randint(0, 999999)) + url = reverse('project-list') + data = { + 'data': { + 'type': 'invalidProjects', + 'attributes': { + 'topic': test_topic, + 'artist': test_artist + } + } + } + response = client.post(url, data=json.dumps(data), content_type='application/vnd.api+json') + assert response.status_code == 409 + content = load_json(response.content) + assert len(content["errors"]) is 1 + assert content["errors"][0]["status"] == "409" + assert content["errors"][0]["detail"] == \ + "The resource object's type (invalidProjects) is not the type that constitute the " \ + "collection represented by the endpoint (one of [researchProjects, artProjects])." + + def test_polymorphism_relations_update(single_company, research_project_factory, client): response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) content = load_json(response.content) @@ -85,7 +108,29 @@ def test_polymorphism_relations_update(single_company, research_project_factory, } response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}), data=json.dumps(content), content_type='application/vnd.api+json') - assert response.status_code is 200 + assert response.status_code == 200 content = load_json(response.content) assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "researchProjects" - assert int(content["data"]["relationships"]["currentProject"]["data"]["id"]) is research_project.pk + assert int(content["data"]["relationships"]["currentProject"]["data"]["id"]) == \ + research_project.pk + + +def test_invalid_type_on_polymorphic_relation(single_company, research_project_factory, client): + response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) + content = load_json(response.content) + assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + + research_project = research_project_factory() + content["data"]["relationships"]["currentProject"]["data"] = { + "type": "invalidProjects", + "id": research_project.pk + } + response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}), + data=json.dumps(content), content_type='application/vnd.api+json') + assert response.status_code == 409 + content = load_json(response.content) + assert len(content["errors"]) is 1 + assert content["errors"][0]["status"] == "409" + assert content["errors"][0]["detail"] == \ + "Incorrect relation type. Expected one of [researchProjects, artProjects], " \ + "received invalidProjects." diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 15b6640b..f1cd6abe 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -30,7 +30,8 @@ class JSONParser(parsers.JSONParser): @staticmethod def parse_attributes(data): - return utils.format_keys(data.get('attributes'), 'underscore') if data.get('attributes') else dict() + return utils.format_keys( + data.get('attributes'), 'underscore') if data.get('attributes') else dict() @staticmethod def parse_relationships(data): @@ -51,40 +52,49 @@ def parse(self, stream, media_type=None, parser_context=None): """ Parses the incoming bytestream as JSON and returns the resulting data """ - result = super(JSONParser, self).parse(stream, media_type=media_type, parser_context=parser_context) + result = super(JSONParser, self).parse( + stream, media_type=media_type, parser_context=parser_context) data = result.get('data') if data: from rest_framework_json_api.views import RelationshipView if isinstance(parser_context['view'], RelationshipView): - # We skip parsing the object as JSONAPI Resource Identifier Object and not a regular Resource Object + # We skip parsing the object as JSONAPI Resource Identifier Object is not a + # regular Resource Object if isinstance(data, list): for resource_identifier_object in data: - if not (resource_identifier_object.get('id') and resource_identifier_object.get('type')): - raise ParseError( - 'Received data contains one or more malformed JSONAPI Resource Identifier Object(s)' - ) + if not (resource_identifier_object.get('id') and + resource_identifier_object.get('type')): + raise ParseError('Received data contains one or more malformed ' + 'JSONAPI Resource Identifier Object(s)') elif not (data.get('id') and data.get('type')): - raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object') + raise ParseError('Received data is not a valid ' + 'JSONAPI Resource Identifier Object') return data request = parser_context.get('request') # Check for inconsistencies - resource_name = utils.get_resource_name(parser_context) - if isinstance(resource_name, six.string_types): - doesnt_match = data.get('type') != resource_name - else: - doesnt_match = data.get('type') not in resource_name - if doesnt_match and request.method in ('PUT', 'POST', 'PATCH'): - raise exceptions.Conflict( - "The resource object's type ({data_type}) is not the type " - "that constitute the collection represented by the endpoint ({resource_type}).".format( - data_type=data.get('type'), - resource_type=resource_name - ) - ) + if request.method in ('PUT', 'POST', 'PATCH'): + resource_name = utils.get_resource_name( + parser_context, expand_polymorphic_types=True) + if isinstance(resource_name, six.string_types): + if data.get('type') != resource_name: + raise exceptions.Conflict( + "The resource object's type ({data_type}) is not the type that " + "constitute the collection represented by the endpoint " + "({resource_type}).".format( + data_type=data.get('type'), + resource_type=resource_name)) + else: + if data.get('type') not in resource_name: + raise exceptions.Conflict( + "The resource object's type ({data_type}) is not the type that " + "constitute the collection represented by the endpoint " + "(one of [{resource_types}]).".format( + data_type=data.get('type'), + resource_types=", ".join(resource_name))) # Construct the return data parsed_data = {'id': data.get('id'), 'type': data.get('type')} diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index ab8754fc..471217ea 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -22,7 +22,6 @@ class ResourceRelatedField(PrimaryKeyRelatedField): 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), 'incorrect_type': _('Incorrect type. Expected resource identifier object, received {data_type}.'), 'incorrect_relation_type': _('Incorrect relation type. Expected {relation_type}, received {received_type}.'), - # 'incorrect_poly_relation_type': _('Incorrect relation type. Expected one of {relation_type}, received {received_type}.'), 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), 'no_match': _('Invalid hyperlink - No URL match.'), @@ -183,7 +182,7 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): _skip_polymorphic_optimization = False default_error_messages = dict(ResourceRelatedField.default_error_messages, **{ - 'incorrect_relation_type': _('Incorrect relation type. Expected one of {relation_type}, ' + 'incorrect_relation_type': _('Incorrect relation type. Expected one of [{relation_type}], ' 'received {received_type}.'), }) @@ -207,7 +206,7 @@ def to_internal_value(self, data): if 'id' not in data: self.fail('missing_id') - expected_relation_types = get_resource_type_from_serializer(self.polymorphic_serializer) + expected_relation_types = self.polymorphic_serializer.get_polymorphic_types() if data['type'] not in expected_relation_types: self.conflict('incorrect_relation_type', relation_type=", ".join( diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index e95863c1..a5457e1c 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -187,11 +187,12 @@ def __new__(cls, name, bases, attrs): serializer: serializer.Meta.model for serializer in polymorphic_serializers} model_to_serializer = { serializer.Meta.model: serializer for serializer in polymorphic_serializers} - type_to_model = { - get_resource_type_from_model(model): model for model in model_to_serializer.keys()} + type_to_serializer = { + get_resource_type_from_serializer(serializer): serializer for + serializer in polymorphic_serializers} setattr(new_class, '_poly_serializer_model_map', serializer_to_model) setattr(new_class, '_poly_model_serializer_map', model_to_serializer) - setattr(new_class, '_poly_type_model_map', type_to_model) + setattr(new_class, '_poly_type_serializer_map', type_to_serializer) return new_class @@ -213,51 +214,62 @@ def get_fields(self): raise Exception("Cannot get fields from a polymorphic serializer given a queryset") return super(PolymorphicModelSerializer, self).get_fields() - def get_polymorphic_serializer_for_instance(self, instance): + @classmethod + def get_polymorphic_serializer_for_instance(cls, instance): """ Return the polymorphic serializer associated with the given instance/model. Raise `NotImplementedError` if no serializer is found for the given model. This usually means that a serializer is missing in the class's `polymorphic_serializers` attribute. """ try: - return self._poly_model_serializer_map[instance._meta.model] + return cls._poly_model_serializer_map[instance._meta.model] except KeyError: raise NotImplementedError( "No polymorphic serializer has been found for model {}".format( instance._meta.model.__name__)) - def get_polymorphic_model_for_serializer(self, serializer): + @classmethod + def get_polymorphic_model_for_serializer(cls, serializer): """ Return the polymorphic model associated with the given serializer. Raise `NotImplementedError` if no model is found for the given serializer. This usually means that a serializer is missing in the class's `polymorphic_serializers` attribute. """ try: - return self._poly_serializer_model_map[serializer] + return cls._poly_serializer_model_map[serializer] except KeyError: raise NotImplementedError( "No polymorphic model has been found for serializer {}".format(serializer.__name__)) - def get_polymorphic_model_for_type(self, obj_type): + @classmethod + def get_polymorphic_serializer_for_type(cls, obj_type): """ - Return the polymorphic model associated with the given type. - Raise `NotImplementedError` if no model is found for the given type. This usually + Return the polymorphic serializer associated with the given type. + Raise `NotImplementedError` if no serializer is found for the given type. This usually means that a serializer is missing in the class's `polymorphic_serializers` attribute. """ try: - return self._poly_type_model_map[obj_type] + return cls._poly_type_serializer_map[obj_type] except KeyError: raise NotImplementedError( - "No polymorphic model has been found for type {}".format(obj_type)) + "No polymorphic serializer has been found for type {}".format(obj_type)) - def get_polymorphic_serializer_for_type(self, obj_type): + @classmethod + def get_polymorphic_model_for_type(cls, obj_type): """ - Return the polymorphic serializer associated with the given type. - Raise `NotImplementedError` if no serializer is found for the given type. This usually + Return the polymorphic model associated with the given type. + Raise `NotImplementedError` if no model is found for the given type. This usually means that a serializer is missing in the class's `polymorphic_serializers` attribute. """ - return self.get_polymorphic_serializer_for_instance( - self.get_polymorphic_model_for_type(obj_type)) + return cls.get_polymorphic_model_for_serializer( + cls.get_polymorphic_serializer_for_type(obj_type)) + + @classmethod + def get_polymorphic_types(cls): + """ + Return the list of accepted types. + """ + return cls._poly_type_serializer_map.keys() def to_representation(self, instance): """ @@ -272,10 +284,10 @@ def to_internal_value(self, data): appropriate polymorphic serializer and use this to handle internal value. """ received_type = data.get('type') - expected_types = self._poly_type_model_map.keys() + expected_types = self.get_polymorphic_types() if received_type not in expected_types: raise Conflict( - 'Incorrect relation type. Expected on of {expected_types}, ' + 'Incorrect relation type. Expected on of [{expected_types}], ' 'received {received_type}.'.format( expected_types=', '.join(expected_types), received_type=received_type)) serializer_class = self.get_polymorphic_serializer_for_type(received_type) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 03762ef7..95e036ca 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -31,10 +31,11 @@ POLYMORPHIC_ANCESTORS += (ancestor_class,) -def get_resource_name(context): +def get_resource_name(context, expand_polymorphic_types=False): """ Return the name of a resource. """ + from . import serializers view = context.get('view') # Sanity check to make sure we have a view. @@ -56,7 +57,11 @@ def get_resource_name(context): except AttributeError: try: serializer = view.get_serializer_class() - return get_resource_type_from_serializer(serializer) + if issubclass(serializer, serializers.PolymorphicModelSerializer) and \ + expand_polymorphic_types: + return serializer.get_polymorphic_types() + else: + return get_resource_type_from_serializer(serializer) except AttributeError: try: resource_name = get_resource_type_from_model(view.model) @@ -235,9 +240,7 @@ def get_resource_type_from_manager(manager): def get_resource_type_from_serializer(serializer): - if hasattr(serializer, 'polymorphic_serializers'): - return [get_resource_type_from_serializer(s) for s in serializer.polymorphic_serializers] - elif hasattr(serializer.Meta, 'resource_name'): + if hasattr(serializer.Meta, 'resource_name'): return serializer.Meta.resource_name else: return get_resource_type_from_model(serializer.Meta.model)