Skip to content

Commit b02e1f6

Browse files
committed
Merge pull request #1 from leo-naeka/zerocater_polymorphism
Polymorphism improvements
2 parents 960b258 + fe7b63a commit b02e1f6

File tree

9 files changed

+376
-94
lines changed

9 files changed

+376
-94
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pip-delete-this-directory.txt
3030

3131
# Tox
3232
.tox/
33+
.cache/
34+
.python-version
3335

3436
# VirtualEnv
3537
.venv/

docs/usage.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ class LineItemViewSet(viewsets.ModelViewSet):
375375

376376
### RelationshipView
377377
`rest_framework_json_api.views.RelationshipView` is used to build
378-
relationship views (see the
378+
relationship views (see the
379379
[JSON API spec](http://jsonapi.org/format/#fetching-relationships)).
380380
The `self` link on a relationship object should point to the corresponding
381381
relationship view.
@@ -425,7 +425,9 @@ field_name_mapping = {
425425

426426
### Working with polymorphic resources
427427

428-
This package can defer the resolution of the type of polymorphic models instances to get the appropriate type.
428+
#### Extraction of the polymorphic type
429+
430+
This package can defer the resolution of the type of polymorphic models instances to retrieve the appropriate type.
429431
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.
430432

431433
Polymorphic ancestors must be defined on settings like this:
@@ -436,6 +438,48 @@ JSON_API_POLYMORPHIC_ANCESTORS = (
436438
)
437439
```
438440

441+
#### Writing polymorphic resources
442+
443+
A polymorphic endpoint can be setup if associated with a polymorphic serializer.
444+
A polymorphic serializer take care of (de)serializing the correct instances types and can be defined like this:
445+
446+
```python
447+
class ProjectSerializer(serializers.PolymorphicModelSerializer):
448+
polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer]
449+
450+
class Meta:
451+
model = models.Project
452+
```
453+
454+
It must inherit from `serializers.PolymorphicModelSerializer` and define the `polymorphic_serializers` list.
455+
This attribute defines the accepted resource types.
456+
457+
458+
Polymorphic relations can also be handled with `relations.PolymorphicResourceRelatedField` like this:
459+
460+
```python
461+
class CompanySerializer(serializers.ModelSerializer):
462+
current_project = relations.PolymorphicResourceRelatedField(
463+
ProjectSerializer, queryset=models.Project.objects.all())
464+
future_projects = relations.PolymorphicResourceRelatedField(
465+
ProjectSerializer, queryset=models.Project.objects.all(), many=True)
466+
467+
class Meta:
468+
model = models.Company
469+
```
470+
471+
They must be explicitely declared with the `polymorphic_serializer` (first positional argument) correctly defined.
472+
It must be a subclass of `serializers.PolymorphicModelSerializer`.
473+
474+
<div class="warning">
475+
<strong>Note:</strong>
476+
Polymorphic resources are not compatible with
477+
<code class="docutils literal">
478+
<span class="pre">resource_name</span>
479+
</code>
480+
defined on the view.
481+
</div>
482+
439483
### Meta
440484

441485
You may add metadata to the rendered json in two different ways: `meta_fields` and `get_root_meta`.

example/migrations/0002_auto_20160513_0857.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
# -*- coding: utf-8 -*-
22
# Generated by Django 1.9.6 on 2016-05-13 08:57
33
from __future__ import unicode_literals
4+
from distutils.version import LooseVersion
45

56
from django.db import migrations, models
67
import django.db.models.deletion
8+
import django
79

810

911
class Migration(migrations.Migration):
1012

11-
dependencies = [
12-
('contenttypes', '0002_remove_content_type_name'),
13-
('example', '0001_initial'),
14-
]
13+
# TODO: Must be removed as soon as Django 1.7 support is dropped
14+
if django.get_version() < LooseVersion('1.8'):
15+
dependencies = [
16+
('contenttypes', '0001_initial'),
17+
('example', '0001_initial'),
18+
]
19+
else:
20+
dependencies = [
21+
('contenttypes', '0002_remove_content_type_name'),
22+
('example', '0001_initial'),
23+
]
1524

1625
operations = [
1726
migrations.CreateModel(

example/serializers.py

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from datetime import datetime
2-
from django.db.models.query import QuerySet
3-
from rest_framework.utils.serializer_helpers import BindingDict
4-
from rest_framework_json_api import serializers, relations, utils
2+
from rest_framework_json_api import serializers, relations
53
from example import models
64

75

@@ -40,15 +38,15 @@ def __init__(self, *args, **kwargs):
4038
}
4139

4240
body_format = serializers.SerializerMethodField()
43-
# many related from model
41+
# Many related from model
4442
comments = relations.ResourceRelatedField(
45-
source='comment_set', many=True, read_only=True)
46-
# many related from serializer
43+
source='comment_set', many=True, read_only=True)
44+
# Many related from serializer
4745
suggested = relations.SerializerMethodResourceRelatedField(
48-
source='get_suggested', model=models.Entry, many=True, read_only=True)
49-
# single related from serializer
46+
source='get_suggested', model=models.Entry, many=True, read_only=True)
47+
# Single related from serializer
5048
featured = relations.SerializerMethodResourceRelatedField(
51-
source='get_featured', model=models.Entry, read_only=True)
49+
source='get_featured', model=models.Entry, read_only=True)
5250

5351
def get_suggested(self, obj):
5452
return models.Entry.objects.exclude(pk=obj.pk)
@@ -107,53 +105,20 @@ class Meta:
107105
exclude = ('polymorphic_ctype',)
108106

109107

110-
class ProjectSerializer(serializers.ModelSerializer):
111-
112-
polymorphic_serializers = [
113-
{'model': models.ArtProject, 'serializer': ArtProjectSerializer},
114-
{'model': models.ResearchProject, 'serializer': ResearchProjectSerializer},
115-
]
108+
class ProjectSerializer(serializers.PolymorphicModelSerializer):
109+
polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer]
116110

117111
class Meta:
118112
model = models.Project
119113
exclude = ('polymorphic_ctype',)
120114

121-
def _get_actual_serializer_from_instance(self, instance):
122-
for info in self.polymorphic_serializers:
123-
if isinstance(instance, info.get('model')):
124-
actual_serializer = info.get('serializer')
125-
return actual_serializer(instance, context=self.context)
126-
127-
@property
128-
def fields(self):
129-
_fields = BindingDict(self)
130-
for key, value in self.get_fields().items():
131-
_fields[key] = value
132-
return _fields
133-
134-
def get_fields(self):
135-
if self.instance is not None:
136-
if not isinstance(self.instance, QuerySet):
137-
return self._get_actual_serializer_from_instance(self.instance).get_fields()
138-
else:
139-
raise Exception("Cannot get fields from a polymorphic serializer given a queryset")
140-
return super(ProjectSerializer, self).get_fields()
141-
142-
def to_representation(self, instance):
143-
# Handle polymorphism
144-
return self._get_actual_serializer_from_instance(instance).to_representation(instance)
145-
146-
def to_internal_value(self, data):
147-
data_type = data.get('type')
148-
for info in self.polymorphic_serializers:
149-
actual_serializer = info['serializer']
150-
if data_type == utils.get_resource_type_from_serializer(actual_serializer):
151-
self.__class__ = actual_serializer
152-
return actual_serializer(data, context=self.context).to_internal_value(data)
153-
raise Exception("Could not deserialize")
154-
155115

156116
class CompanySerializer(serializers.ModelSerializer):
117+
current_project = relations.PolymorphicResourceRelatedField(
118+
ProjectSerializer, queryset=models.Project.objects.all())
119+
future_projects = relations.PolymorphicResourceRelatedField(
120+
ProjectSerializer, queryset=models.Project.objects.all(), many=True)
121+
157122
included_serializers = {
158123
'current_project': ProjectSerializer,
159124
'future_projects': ProjectSerializer,

example/tests/integration/test_polymorphism.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ def test_polymorphism_on_included_relations(single_company, client):
2929
assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects"
3030
assert [rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]] == [
3131
"researchProjects", "artProjects"]
32-
assert [x.get('type') for x in content.get('included')] == ['artProjects', 'artProjects', 'researchProjects'], \
33-
'Detail included types are incorrect'
32+
assert [x.get('type') for x in content.get('included')] == [
33+
'artProjects', 'artProjects', 'researchProjects'], 'Detail included types are incorrect'
3434
# Ensure that the child fields are present.
3535
assert content.get('included')[0].get('attributes').get('artist') is not None
3636
assert content.get('included')[1].get('attributes').get('artist') is not None
3737
assert content.get('included')[2].get('attributes').get('supervisor') is not None
3838

39+
3940
def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, client):
4041
url = reverse("project-detail", kwargs={'pk': single_art_project.pk})
4142
response = client.get(url)
@@ -50,6 +51,7 @@ def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, clie
5051
assert new_content['data']['attributes']['topic'] == test_topic
5152
assert new_content['data']['attributes']['artist'] == test_artist
5253

54+
5355
def test_polymorphism_on_polymorphic_model_list_post(client):
5456
test_topic = 'New test topic {}'.format(random.randint(0, 999999))
5557
test_artist = 'test-{}'.format(random.randint(0, 999999))
@@ -69,3 +71,66 @@ def test_polymorphism_on_polymorphic_model_list_post(client):
6971
assert content["data"]["type"] == "artProjects"
7072
assert content['data']['attributes']['topic'] == test_topic
7173
assert content['data']['attributes']['artist'] == test_artist
74+
75+
76+
def test_invalid_type_on_polymorphic_model(client):
77+
test_topic = 'New test topic {}'.format(random.randint(0, 999999))
78+
test_artist = 'test-{}'.format(random.randint(0, 999999))
79+
url = reverse('project-list')
80+
data = {
81+
'data': {
82+
'type': 'invalidProjects',
83+
'attributes': {
84+
'topic': test_topic,
85+
'artist': test_artist
86+
}
87+
}
88+
}
89+
response = client.post(url, data=json.dumps(data), content_type='application/vnd.api+json')
90+
assert response.status_code == 409
91+
content = load_json(response.content)
92+
assert len(content["errors"]) is 1
93+
assert content["errors"][0]["status"] == "409"
94+
assert content["errors"][0]["detail"] == \
95+
"The resource object's type (invalidProjects) is not the type that constitute the " \
96+
"collection represented by the endpoint (one of [researchProjects, artProjects])."
97+
98+
99+
def test_polymorphism_relations_update(single_company, research_project_factory, client):
100+
response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk}))
101+
content = load_json(response.content)
102+
assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects"
103+
104+
research_project = research_project_factory()
105+
content["data"]["relationships"]["currentProject"]["data"] = {
106+
"type": "researchProjects",
107+
"id": research_project.pk
108+
}
109+
response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}),
110+
data=json.dumps(content), content_type='application/vnd.api+json')
111+
assert response.status_code == 200
112+
content = load_json(response.content)
113+
assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "researchProjects"
114+
assert int(content["data"]["relationships"]["currentProject"]["data"]["id"]) == \
115+
research_project.pk
116+
117+
118+
def test_invalid_type_on_polymorphic_relation(single_company, research_project_factory, client):
119+
response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk}))
120+
content = load_json(response.content)
121+
assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects"
122+
123+
research_project = research_project_factory()
124+
content["data"]["relationships"]["currentProject"]["data"] = {
125+
"type": "invalidProjects",
126+
"id": research_project.pk
127+
}
128+
response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}),
129+
data=json.dumps(content), content_type='application/vnd.api+json')
130+
assert response.status_code == 409
131+
content = load_json(response.content)
132+
assert len(content["errors"]) is 1
133+
assert content["errors"][0]["status"] == "409"
134+
assert content["errors"][0]["detail"] == \
135+
"Incorrect relation type. Expected one of [researchProjects, artProjects], " \
136+
"received invalidProjects."

rest_framework_json_api/parsers.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class JSONParser(parsers.JSONParser):
3030

3131
@staticmethod
3232
def parse_attributes(data):
33-
return utils.format_keys(data.get('attributes'), 'underscore') if data.get('attributes') else dict()
33+
return utils.format_keys(
34+
data.get('attributes'), 'underscore') if data.get('attributes') else dict()
3435

3536
@staticmethod
3637
def parse_relationships(data):
@@ -51,40 +52,49 @@ def parse(self, stream, media_type=None, parser_context=None):
5152
"""
5253
Parses the incoming bytestream as JSON and returns the resulting data
5354
"""
54-
result = super(JSONParser, self).parse(stream, media_type=media_type, parser_context=parser_context)
55+
result = super(JSONParser, self).parse(
56+
stream, media_type=media_type, parser_context=parser_context)
5557
data = result.get('data')
5658

5759
if data:
5860
from rest_framework_json_api.views import RelationshipView
5961
if isinstance(parser_context['view'], RelationshipView):
60-
# We skip parsing the object as JSONAPI Resource Identifier Object and not a regular Resource Object
62+
# We skip parsing the object as JSONAPI Resource Identifier Object is not a
63+
# regular Resource Object
6164
if isinstance(data, list):
6265
for resource_identifier_object in data:
63-
if not (resource_identifier_object.get('id') and resource_identifier_object.get('type')):
64-
raise ParseError(
65-
'Received data contains one or more malformed JSONAPI Resource Identifier Object(s)'
66-
)
66+
if not (resource_identifier_object.get('id') and
67+
resource_identifier_object.get('type')):
68+
raise ParseError('Received data contains one or more malformed '
69+
'JSONAPI Resource Identifier Object(s)')
6770
elif not (data.get('id') and data.get('type')):
68-
raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object')
71+
raise ParseError('Received data is not a valid '
72+
'JSONAPI Resource Identifier Object')
6973

7074
return data
7175

7276
request = parser_context.get('request')
7377

7478
# Check for inconsistencies
75-
resource_name = utils.get_resource_name(parser_context)
76-
if isinstance(resource_name, six.string_types):
77-
doesnt_match = data.get('type') != resource_name
78-
else:
79-
doesnt_match = data.get('type') not in resource_name
80-
if doesnt_match and request.method in ('PUT', 'POST', 'PATCH'):
81-
raise exceptions.Conflict(
82-
"The resource object's type ({data_type}) is not the type "
83-
"that constitute the collection represented by the endpoint ({resource_type}).".format(
84-
data_type=data.get('type'),
85-
resource_type=resource_name
86-
)
87-
)
79+
if request.method in ('PUT', 'POST', 'PATCH'):
80+
resource_name = utils.get_resource_name(
81+
parser_context, expand_polymorphic_types=True)
82+
if isinstance(resource_name, six.string_types):
83+
if data.get('type') != resource_name:
84+
raise exceptions.Conflict(
85+
"The resource object's type ({data_type}) is not the type that "
86+
"constitute the collection represented by the endpoint "
87+
"({resource_type}).".format(
88+
data_type=data.get('type'),
89+
resource_type=resource_name))
90+
else:
91+
if data.get('type') not in resource_name:
92+
raise exceptions.Conflict(
93+
"The resource object's type ({data_type}) is not the type that "
94+
"constitute the collection represented by the endpoint "
95+
"(one of [{resource_types}]).".format(
96+
data_type=data.get('type'),
97+
resource_types=", ".join(resource_name)))
8898

8999
# Construct the return data
90100
parsed_data = {'id': data.get('id'), 'type': data.get('type')}

0 commit comments

Comments
 (0)