Skip to content

Commit bb905bf

Browse files
committed
Fix RelationshipView.
Improve comment describing the need to monkey-patch view.action.
1 parent 407e119 commit bb905bf

File tree

2 files changed

+61
-120
lines changed

2 files changed

+61
-120
lines changed

example/tests/test_openapi.py

+2-10
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,7 @@ def test_schema_construction():
128128
def test_schema_related_serializers():
129129
"""
130130
Confirm that paths are generated for related fields. For example:
131-
url path '/authors/{pk}/{related_field>}/' generates:
132-
/authors/{id}/relationships/comments/
133-
/authors/{id}/relationships/entries/
134-
/authors/{id}/relationships/first_entry/ -- Maybe?
131+
/authors/{pk}/{related_field>}
135132
/authors/{id}/comments/
136133
/authors/{id}/entries/
137134
/authors/{id}/first_entry/
@@ -141,12 +138,7 @@ def test_schema_related_serializers():
141138
request = create_request('/')
142139
schema = generator.get_schema(request=request)
143140
# make sure the path's relationship and related {related_field}'s got expanded
144-
assert '/authors/{id}/relationships/entries' in schema['paths']
145-
assert '/authors/{id}/relationships/comments' in schema['paths']
146-
# first_entry is a special case (SerializerMethodRelatedField)
147-
# TODO: '/authors/{id}/relationships/first_entry' supposed to be there?
148-
# It fails when doing the actual GET, so this schema excluding it is OK.
149-
# assert '/authors/{id}/relationships/first_entry/' in schema['paths']
141+
assert '/authors/{id}/relationships/{related_field}' in schema['paths']
150142
assert '/authors/{id}/comments/' in schema['paths']
151143
assert '/authors/{id}/entries/' in schema['paths']
152144
assert '/authors/{id}/first_entry/' in schema['paths']

rest_framework_json_api/schemas/openapi.py

+59-110
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import warnings
22
from urllib.parse import urljoin
33

4-
from django.db.models.fields import related_descriptors as rd
54
from django.utils.module_loading import import_string as import_class_from_dotted_path
65
from rest_framework.fields import empty
76
from rest_framework.relations import ManyRelatedField
87
from rest_framework.schemas import openapi as drf_openapi
98
from rest_framework.schemas.utils import is_list_view
109

11-
from rest_framework_json_api import serializers
12-
from rest_framework_json_api.views import RelationshipView
10+
from rest_framework_json_api import serializers, views
1311

1412

1513
class SchemaGenerator(drf_openapi.SchemaGenerator):
@@ -29,19 +27,6 @@ class SchemaGenerator(drf_openapi.SchemaGenerator):
2927
},
3028
'additionalProperties': False
3129
},
32-
'ResourceIdentifierObject': {
33-
'type': 'object',
34-
'required': ['type', 'id'],
35-
'additionalProperties': False,
36-
'properties': {
37-
'type': {
38-
'$ref': '#/components/schemas/type'
39-
},
40-
'id': {
41-
'$ref': '#/components/schemas/id'
42-
},
43-
},
44-
},
4530
'resource': {
4631
'type': 'object',
4732
'required': ['type', 'id'],
@@ -133,6 +118,18 @@ class SchemaGenerator(drf_openapi.SchemaGenerator):
133118
'items': {'$ref': '#/components/schemas/linkage'},
134119
'uniqueItems': True
135120
},
121+
# A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name
122+
# ResourceIdentifierObject returned by get_component_name()) which serializes type and
123+
# id. These can be lists or individual items depending on whether the relationship is
124+
# toMany or toOne so offer both options since we are not iterating over all the
125+
# possible {related_field}'s but rather rendering one path schema which may represent
126+
# toMany and toOne relationships.
127+
'ResourceIdentifierObject': {
128+
'oneOf': [
129+
{'$ref': '#/components/schemas/relationshipToOne'},
130+
{'$ref': '#/components/schemas/relationshipToMany'}
131+
]
132+
},
136133
'linkage': {
137134
'type': 'object',
138135
'description': "the 'type' and 'id'",
@@ -302,24 +299,23 @@ def get_schema(self, request=None, public=False):
302299
#: - 'action' copy of current view.action (list/fetch) as this gets reset for each request.
303300
expanded_endpoints = []
304301
for path, method, view in view_endpoints:
305-
if isinstance(view, RelationshipView):
306-
expanded_endpoints += self._expand_relationships(path, method, view)
307-
elif hasattr(view, 'action') and view.action == 'retrieve_related':
302+
if hasattr(view, 'action') and view.action == 'retrieve_related':
308303
expanded_endpoints += self._expand_related(path, method, view, view_endpoints)
309304
else:
310305
expanded_endpoints.append((path, method, view, getattr(view, 'action', None)))
311306

312307
for path, method, view, action in expanded_endpoints:
313308
if not self.has_view_permissions(path, method, view):
314309
continue
315-
# kludge to preserve view.action as it changes "globally" for the same ViewSet
316-
# whether it is used for a collection, item or related serializer. _expand_related
317-
# sets it based on whether the related field is a toMany collection or toOne item.
310+
# kludge to preserve view.action as it is 'list' for the parent ViewSet
311+
# but the related viewset that was expanded may be either 'fetch' (to_one) or 'list'
312+
# (to_many). This patches the view.action appropriately so that
313+
# view.schema.get_operation() "does the right thing" for fetch vs. list.
318314
current_action = None
319315
if hasattr(view, 'action'):
320316
current_action = view.action
321317
view.action = action
322-
operation = view.schema.get_operation(path, method, action)
318+
operation = view.schema.get_operation(path, method)
323319
components = view.schema.get_components(path, method)
324320
for k in components.keys():
325321
if k not in components_schemas:
@@ -350,28 +346,6 @@ def get_schema(self, request=None, public=False):
350346

351347
return schema
352348

353-
def _expand_relationships(self, path, method, view):
354-
"""
355-
Expand path containing .../{id}/relationships/{related_field} into list of related fields.
356-
:return:list[tuple(path, method, view, action)]
357-
"""
358-
queryset = view.get_queryset()
359-
if not queryset.model:
360-
return [(path, method, view, getattr(view, 'action', '')), ]
361-
result = []
362-
# TODO: what about serializer-only (non-model) fields?
363-
# Shouldn't this be iterating over serializer fields rather than model fields?
364-
# Look at parent view's serializer to get the list of fields.
365-
# OR maybe like _expand_related?
366-
m = queryset.model
367-
for field in [f for f in dir(m) if not f.startswith('_')]:
368-
attr = getattr(m, field)
369-
if isinstance(attr, (rd.ReverseManyToOneDescriptor, rd.ForwardOneToOneDescriptor)):
370-
action = 'rels' if isinstance(attr, rd.ReverseManyToOneDescriptor) else 'rel'
371-
result.append((path.replace('{related_field}', field), method, view, action))
372-
373-
return result
374-
375349
def _expand_related(self, path, method, view, view_endpoints):
376350
"""
377351
Expand path containing .../{id}/{related_field} into list of related fields
@@ -439,16 +413,12 @@ class AutoSchema(drf_openapi.AutoSchema):
439413
#: ignore all the media types and only generate a JSONAPI schema.
440414
content_types = ['application/vnd.api+json']
441415

442-
def get_operation(self, path, method, action=None):
416+
def get_operation(self, path, method):
443417
"""
444418
JSONAPI adds some standard fields to the API response that are not in upstream DRF:
445419
- some that only apply to GET/HEAD methods.
446420
- collections
447-
- special handling for POST, PATCH, DELETE:
448-
449-
:param action: One of the usual actions for a conventional path (list, retrieve, update,
450-
partial_update, destroy) or special case 'rel' or 'rels' for a singular or
451-
plural relationship.
421+
- special handling for POST, PATCH, DELETE
452422
"""
453423
operation = {}
454424
operation['operationId'] = self.get_operation_id(path, method)
@@ -472,13 +442,13 @@ def get_operation(self, path, method, action=None):
472442
else:
473443
self._add_get_item_response(operation)
474444
elif method == 'POST':
475-
self._add_post_item_response(operation, path, action)
445+
self._add_post_item_response(operation, path)
476446
elif method == 'PATCH':
477-
self._add_patch_item_response(operation, path, action)
447+
self._add_patch_item_response(operation, path)
478448
elif method == 'DELETE':
479449
# should only allow deleting a resource, not a collection
480450
# TODO: implement delete of a relationship in future release.
481-
self._add_delete_item_response(operation, path, action)
451+
self._add_delete_item_response(operation, path)
482452
return operation
483453

484454
def get_operation_id(self, path, method):
@@ -591,11 +561,11 @@ def _get_toplevel_200_response(self, operation, collection=True):
591561
}
592562
}
593563

594-
def _add_post_item_response(self, operation, path, action):
564+
def _add_post_item_response(self, operation, path):
595565
"""
596566
add response for POST of an item to operation
597567
"""
598-
operation['requestBody'] = self.get_request_body(path, 'POST', action)
568+
operation['requestBody'] = self.get_request_body(path, 'POST')
599569
operation['responses'] = {
600570
'201': self._get_toplevel_200_response(operation, collection=False)
601571
}
@@ -610,95 +580,74 @@ def _add_post_item_response(self, operation, path, action):
610580
}
611581
self._add_post_4xx_responses(operation)
612582

613-
def _add_patch_item_response(self, operation, path, action):
583+
def _add_patch_item_response(self, operation, path):
614584
"""
615585
Add PATCH response for an item to operation
616586
"""
617-
operation['requestBody'] = self.get_request_body(path, 'PATCH', action)
587+
operation['requestBody'] = self.get_request_body(path, 'PATCH')
618588
operation['responses'] = {
619589
'200': self._get_toplevel_200_response(operation, collection=False)
620590
}
621591
self._add_patch_4xx_responses(operation)
622592

623-
def _add_delete_item_response(self, operation, path, action):
593+
def _add_delete_item_response(self, operation, path):
624594
"""
625595
add DELETE response for item or relationship(s) to operation
626596
"""
627597
# Only DELETE of relationships has a requestBody
628-
if action in ['rels', 'rel']:
629-
operation['requestBody'] = self.get_request_body(path, 'DELETE', action)
598+
if isinstance(self.view, views.RelationshipView):
599+
operation['requestBody'] = self.get_request_body(path, 'DELETE')
630600
self._add_delete_responses(operation)
631601

632-
def get_request_body(self, path, method, action=None):
602+
def get_request_body(self, path, method):
633603
"""
634604
A request body is required by jsonapi for POST, PATCH, and DELETE methods.
635-
This has an added parameter which is not in upstream DRF:
636-
637-
:param action: None for conventional path; 'rel' or 'rels' for a singular or plural
638-
relationship of a related path, respectively.
639605
"""
640606
serializer = self.get_serializer(path, method)
641607
if not isinstance(serializer, (serializers.BaseSerializer, )):
642608
return {}
609+
is_relationship = isinstance(self.view, views.RelationshipView)
643610

644-
# DRF uses a $ref to the component definition, but this
611+
# DRF uses a $ref to the component schema definition, but this
645612
# doesn't work for jsonapi due to the different required fields based on
646613
# the method, so make those changes and inline another copy of the schema.
647-
# TODO: A future improvement could make this DRYer with multiple components?
648-
item_schema = self.map_serializer(serializer)
649-
650-
# 'type' and 'id' are both required for:
651-
# - all relationship operations
652-
# - regular PATCH or DELETE
653-
# Only 'type' is required for POST: system may assign the 'id'.
654-
if action in ['rels', 'rel']:
655-
item_schema['required'] = ['type', 'id']
656-
elif method in ['PATCH', 'DELETE']:
657-
item_schema['required'] = ['type', 'id']
658-
elif method == 'POST':
659-
item_schema['required'] = ['type']
614+
# TODO: A future improvement could make this DRYer with multiple component schemas:
615+
# A base schema for each viewset that has no required fields
616+
# One subclassed from the base that requires some fields (`type` but not `id` for POST)
617+
# Another subclassed from base with required type/id but no required attributes (PATCH)
660618

661-
if 'attributes' in item_schema['properties']:
619+
if is_relationship:
620+
item_schema = {'$ref': '#/components/schemas/ResourceIdentifierObject'}
621+
else:
622+
item_schema = self.map_serializer(serializer)
623+
if method == 'POST':
624+
# 'type' and 'id' are both required for:
625+
# - all relationship operations
626+
# - regular PATCH or DELETE
627+
# Only 'type' is required for POST: system may assign the 'id'.
628+
item_schema['required'] = ['type']
629+
630+
if 'properties' in item_schema and 'attributes' in item_schema['properties']:
662631
# No required attributes for PATCH
663632
if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']:
664633
del item_schema['properties']['attributes']['required']
665634
# No read_only fields for request.
666635
for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501
667636
if 'readOnly' in schema:
668637
del item_schema['properties']['attributes']['properties'][name]
669-
# relationships special case: plural request body (data is array of items)
670-
if action == 'rels':
671-
return {
672-
'content': {
673-
ct: {
674-
'schema': {
675-
'required': ['data'],
676-
'properties': {
677-
'data': {
678-
'type': 'array',
679-
'items': item_schema
680-
}
681-
}
682-
}
683-
}
684-
for ct in self.content_types
685-
}
686-
}
687-
# singular request body for all other cases
688-
else:
689-
return {
690-
'content': {
691-
ct: {
692-
'schema': {
693-
'required': ['data'],
694-
'properties': {
695-
'data': item_schema
696-
}
638+
return {
639+
'content': {
640+
ct: {
641+
'schema': {
642+
'required': ['data'],
643+
'properties': {
644+
'data': item_schema
697645
}
698646
}
699-
for ct in self.content_types
700647
}
648+
for ct in self.content_types
701649
}
650+
}
702651

703652
def map_serializer(self, serializer):
704653
"""

0 commit comments

Comments
 (0)