Skip to content

Polymorphism improvements #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 25, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pip-delete-this-directory.txt

# Tox
.tox/
.cache/
.python-version

# VirtualEnv
.venv/
48 changes: 46 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -436,6 +438,48 @@ 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`.

<div class="warning">
<strong>Note:</strong>
Polymorphic resources are not compatible with
<code class="docutils literal">
<span class="pre">resource_name</span>
</code>
defined on the view.
</div>

### Meta

You may add metadata to the rendered json in two different ways: `meta_fields` and `get_root_meta`.
Expand Down
17 changes: 13 additions & 4 deletions example/migrations/0002_auto_20160513_0857.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
63 changes: 14 additions & 49 deletions example/serializers.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -107,53 +105,20 @@ 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):
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,
Expand Down
69 changes: 67 additions & 2 deletions example/tests/integration/test_polymorphism.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand All @@ -69,3 +71,66 @@ 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_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)
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 == 200
content = load_json(response.content)
assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "researchProjects"
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."
52 changes: 31 additions & 21 deletions rest_framework_json_api/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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')}
Expand Down
Loading