1
1
import warnings
2
2
from urllib .parse import urljoin
3
3
4
- from django .db .models .fields import related_descriptors as rd
5
4
from django .utils .module_loading import import_string as import_class_from_dotted_path
6
5
from rest_framework .fields import empty
7
6
from rest_framework .relations import ManyRelatedField
8
7
from rest_framework .schemas import openapi as drf_openapi
9
8
from rest_framework .schemas .utils import is_list_view
10
9
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
13
11
14
12
15
13
class SchemaGenerator (drf_openapi .SchemaGenerator ):
@@ -29,19 +27,6 @@ class SchemaGenerator(drf_openapi.SchemaGenerator):
29
27
},
30
28
'additionalProperties' : False
31
29
},
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
- },
45
30
'resource' : {
46
31
'type' : 'object' ,
47
32
'required' : ['type' , 'id' ],
@@ -133,6 +118,18 @@ class SchemaGenerator(drf_openapi.SchemaGenerator):
133
118
'items' : {'$ref' : '#/components/schemas/linkage' },
134
119
'uniqueItems' : True
135
120
},
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
+ },
136
133
'linkage' : {
137
134
'type' : 'object' ,
138
135
'description' : "the 'type' and 'id'" ,
@@ -302,24 +299,23 @@ def get_schema(self, request=None, public=False):
302
299
#: - 'action' copy of current view.action (list/fetch) as this gets reset for each request.
303
300
expanded_endpoints = []
304
301
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' :
308
303
expanded_endpoints += self ._expand_related (path , method , view , view_endpoints )
309
304
else :
310
305
expanded_endpoints .append ((path , method , view , getattr (view , 'action' , None )))
311
306
312
307
for path , method , view , action in expanded_endpoints :
313
308
if not self .has_view_permissions (path , method , view ):
314
309
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.
318
314
current_action = None
319
315
if hasattr (view , 'action' ):
320
316
current_action = view .action
321
317
view .action = action
322
- operation = view .schema .get_operation (path , method , action )
318
+ operation = view .schema .get_operation (path , method )
323
319
components = view .schema .get_components (path , method )
324
320
for k in components .keys ():
325
321
if k not in components_schemas :
@@ -350,28 +346,6 @@ def get_schema(self, request=None, public=False):
350
346
351
347
return schema
352
348
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
-
375
349
def _expand_related (self , path , method , view , view_endpoints ):
376
350
"""
377
351
Expand path containing .../{id}/{related_field} into list of related fields
@@ -439,16 +413,12 @@ class AutoSchema(drf_openapi.AutoSchema):
439
413
#: ignore all the media types and only generate a JSONAPI schema.
440
414
content_types = ['application/vnd.api+json' ]
441
415
442
- def get_operation (self , path , method , action = None ):
416
+ def get_operation (self , path , method ):
443
417
"""
444
418
JSONAPI adds some standard fields to the API response that are not in upstream DRF:
445
419
- some that only apply to GET/HEAD methods.
446
420
- 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
452
422
"""
453
423
operation = {}
454
424
operation ['operationId' ] = self .get_operation_id (path , method )
@@ -472,13 +442,13 @@ def get_operation(self, path, method, action=None):
472
442
else :
473
443
self ._add_get_item_response (operation )
474
444
elif method == 'POST' :
475
- self ._add_post_item_response (operation , path , action )
445
+ self ._add_post_item_response (operation , path )
476
446
elif method == 'PATCH' :
477
- self ._add_patch_item_response (operation , path , action )
447
+ self ._add_patch_item_response (operation , path )
478
448
elif method == 'DELETE' :
479
449
# should only allow deleting a resource, not a collection
480
450
# 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 )
482
452
return operation
483
453
484
454
def get_operation_id (self , path , method ):
@@ -591,11 +561,11 @@ def _get_toplevel_200_response(self, operation, collection=True):
591
561
}
592
562
}
593
563
594
- def _add_post_item_response (self , operation , path , action ):
564
+ def _add_post_item_response (self , operation , path ):
595
565
"""
596
566
add response for POST of an item to operation
597
567
"""
598
- operation ['requestBody' ] = self .get_request_body (path , 'POST' , action )
568
+ operation ['requestBody' ] = self .get_request_body (path , 'POST' )
599
569
operation ['responses' ] = {
600
570
'201' : self ._get_toplevel_200_response (operation , collection = False )
601
571
}
@@ -610,95 +580,74 @@ def _add_post_item_response(self, operation, path, action):
610
580
}
611
581
self ._add_post_4xx_responses (operation )
612
582
613
- def _add_patch_item_response (self , operation , path , action ):
583
+ def _add_patch_item_response (self , operation , path ):
614
584
"""
615
585
Add PATCH response for an item to operation
616
586
"""
617
- operation ['requestBody' ] = self .get_request_body (path , 'PATCH' , action )
587
+ operation ['requestBody' ] = self .get_request_body (path , 'PATCH' )
618
588
operation ['responses' ] = {
619
589
'200' : self ._get_toplevel_200_response (operation , collection = False )
620
590
}
621
591
self ._add_patch_4xx_responses (operation )
622
592
623
- def _add_delete_item_response (self , operation , path , action ):
593
+ def _add_delete_item_response (self , operation , path ):
624
594
"""
625
595
add DELETE response for item or relationship(s) to operation
626
596
"""
627
597
# 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' )
630
600
self ._add_delete_responses (operation )
631
601
632
- def get_request_body (self , path , method , action = None ):
602
+ def get_request_body (self , path , method ):
633
603
"""
634
604
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.
639
605
"""
640
606
serializer = self .get_serializer (path , method )
641
607
if not isinstance (serializer , (serializers .BaseSerializer , )):
642
608
return {}
609
+ is_relationship = isinstance (self .view , views .RelationshipView )
643
610
644
- # DRF uses a $ref to the component definition, but this
611
+ # DRF uses a $ref to the component schema definition, but this
645
612
# doesn't work for jsonapi due to the different required fields based on
646
613
# 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)
660
618
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' ]:
662
631
# No required attributes for PATCH
663
632
if method in ['PATCH' , 'PUT' ] and 'required' in item_schema ['properties' ]['attributes' ]:
664
633
del item_schema ['properties' ]['attributes' ]['required' ]
665
634
# No read_only fields for request.
666
635
for name , schema in item_schema ['properties' ]['attributes' ]['properties' ].copy ().items (): # noqa E501
667
636
if 'readOnly' in schema :
668
637
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
697
645
}
698
646
}
699
- for ct in self .content_types
700
647
}
648
+ for ct in self .content_types
701
649
}
650
+ }
702
651
703
652
def map_serializer (self , serializer ):
704
653
"""
0 commit comments