Skip to content

Commit 2a5c2f3

Browse files
authored
Added OpenAPI tags to schemas. (#7184)
1 parent e32ffbb commit 2a5c2f3

File tree

3 files changed

+146
-0
lines changed

3 files changed

+146
-0
lines changed

docs/api-guide/schemas.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,81 @@ This also applies to extra actions for `ViewSet`s:
215215
If you wish to provide a base `AutoSchema` subclass to be used throughout your
216216
project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately.
217217

218+
219+
### Grouping Operations With Tags
220+
221+
Tags can be used to group logical operations. Each tag name in the list MUST be unique.
222+
223+
---
224+
#### Django REST Framework generates tags automatically with the following logic:
225+
226+
Tag name will be first element from the path. Also, any `_` in path name will be replaced by a `-`.
227+
Consider below examples.
228+
229+
Example 1: Consider a user management system. The following table will illustrate the tag generation logic.
230+
Here first element from the paths is: `users`. Hence tag wil be `users`
231+
232+
Http Method | Path | Tags
233+
-------------------------------------|-------------------|-------------
234+
PUT, PATCH, GET(Retrieve), DELETE | /users/{id}/ | ['users']
235+
POST, GET(List) | /users/ | ['users']
236+
237+
Example 2: Consider a restaurant management system. The System has restaurants. Each restaurant has branches.
238+
Consider REST APIs to deal with a branch of a particular restaurant.
239+
Here first element from the paths is: `restaurants`. Hence tag wil be `restaurants`.
240+
241+
Http Method | Path | Tags
242+
-------------------------------------|----------------------------------------------------|-------------------
243+
PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | ['restaurants']
244+
POST, GET(List): | /restaurants/{restaurant_id}/branches/ | ['restaurants']
245+
246+
Example 3: Consider Order items for an e commerce company.
247+
248+
Http Method | Path | Tags
249+
-------------------------------------|-------------------------|-------------
250+
PUT, PATCH, GET(Retrieve), DELETE | /order_items/{id}/ | ['order-items']
251+
POST, GET(List) | /order_items/ | ['order-items']
252+
253+
254+
---
255+
#### Overriding auto generated tags:
256+
You can override auto-generated tags by passing `tags` argument to the constructor of `AutoSchema`. `tags` argument must be a list or tuple of string.
257+
```python
258+
from rest_framework.schemas.openapi import AutoSchema
259+
from rest_framework.views import APIView
260+
261+
class MyView(APIView):
262+
schema = AutoSchema(tags=['tag1', 'tag2'])
263+
...
264+
```
265+
266+
If you need more customization, you can override the `get_tags` method of `AutoSchema` class. Consider the following example:
267+
268+
```python
269+
from rest_framework.schemas.openapi import AutoSchema
270+
from rest_framework.views import APIView
271+
272+
class MySchema(AutoSchema):
273+
...
274+
def get_tags(self, path, method):
275+
if method == 'POST':
276+
tags = ['tag1', 'tag2']
277+
elif method == 'GET':
278+
tags = ['tag2', 'tag3']
279+
elif path == '/example/path/':
280+
tags = ['tag3', 'tag4']
281+
else:
282+
tags = ['tag5', 'tag6', 'tag7']
283+
284+
return tags
285+
286+
class MyView(APIView):
287+
schema = MySchema()
288+
...
289+
```
290+
291+
218292
[openapi]: https://github.com/OAI/OpenAPI-Specification
219293
[openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions
220294
[openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
295+
[openapi-tags]: https://swagger.io/specification/#tagObject

rest_framework/schemas/openapi.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ def get_schema(self, request=None, public=False):
7171

7272
class AutoSchema(ViewInspector):
7373

74+
def __init__(self, tags=None):
75+
if tags and not all(isinstance(tag, str) for tag in tags):
76+
raise ValueError('tags must be a list or tuple of string.')
77+
self._tags = tags
78+
super().__init__()
79+
7480
request_media_types = []
7581
response_media_types = []
7682

@@ -98,6 +104,7 @@ def get_operation(self, path, method):
98104
if request_body:
99105
operation['requestBody'] = request_body
100106
operation['responses'] = self._get_responses(path, method)
107+
operation['tags'] = self.get_tags(path, method)
101108

102109
return operation
103110

@@ -564,3 +571,16 @@ def _get_responses(self, path, method):
564571
'description': ""
565572
}
566573
}
574+
575+
def get_tags(self, path, method):
576+
# If user have specified tags, use them.
577+
if self._tags:
578+
return self._tags
579+
580+
# First element of a specific path could be valid tag. This is a fallback solution.
581+
# PUT, PATCH, GET(Retrieve), DELETE: /user_profile/{id}/ tags = [user-profile]
582+
# POST, GET(List): /user_profile/ tags = [user-profile]
583+
if path.startswith('/'):
584+
path = path[1:]
585+
586+
return [path.split('/')[0].replace('_', '-')]

tests/schemas/test_openapi.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def test_path_without_parameters(self):
126126
'operationId': 'listDocStringExamples',
127127
'description': 'A description of my GET operation.',
128128
'parameters': [],
129+
'tags': ['example'],
129130
'responses': {
130131
'200': {
131132
'description': '',
@@ -166,6 +167,7 @@ def test_path_with_id_parameter(self):
166167
'type': 'string',
167168
},
168169
}],
170+
'tags': ['example'],
169171
'responses': {
170172
'200': {
171173
'description': '',
@@ -696,6 +698,55 @@ def test_serializer_validators(self):
696698
assert properties['ip']['type'] == 'string'
697699
assert 'format' not in properties['ip']
698700

701+
def test_overridden_tags(self):
702+
class ExampleStringTagsViewSet(views.ExampleGenericAPIView):
703+
schema = AutoSchema(tags=['example1', 'example2'])
704+
705+
url_patterns = [
706+
url(r'^test/?$', ExampleStringTagsViewSet.as_view()),
707+
]
708+
generator = SchemaGenerator(patterns=url_patterns)
709+
schema = generator.get_schema(request=create_request('/'))
710+
assert schema['paths']['/test/']['get']['tags'] == ['example1', 'example2']
711+
712+
def test_overridden_get_tags_method(self):
713+
class MySchema(AutoSchema):
714+
def get_tags(self, path, method):
715+
if path.endswith('/new/'):
716+
tags = ['tag1', 'tag2']
717+
elif path.endswith('/old/'):
718+
tags = ['tag2', 'tag3']
719+
else:
720+
tags = ['tag4', 'tag5']
721+
722+
return tags
723+
724+
class ExampleStringTagsViewSet(views.ExampleGenericViewSet):
725+
schema = MySchema()
726+
727+
router = routers.SimpleRouter()
728+
router.register('example', ExampleStringTagsViewSet, basename="example")
729+
generator = SchemaGenerator(patterns=router.urls)
730+
schema = generator.get_schema(request=create_request('/'))
731+
assert schema['paths']['/example/new/']['get']['tags'] == ['tag1', 'tag2']
732+
assert schema['paths']['/example/old/']['get']['tags'] == ['tag2', 'tag3']
733+
734+
def test_auto_generated_apiview_tags(self):
735+
class RestaurantAPIView(views.ExampleGenericAPIView):
736+
pass
737+
738+
class BranchAPIView(views.ExampleGenericAPIView):
739+
pass
740+
741+
url_patterns = [
742+
url(r'^any-dash_underscore/?$', RestaurantAPIView.as_view()),
743+
url(r'^restaurants/branches/?$', BranchAPIView.as_view())
744+
]
745+
generator = SchemaGenerator(patterns=url_patterns)
746+
schema = generator.get_schema(request=create_request('/'))
747+
assert schema['paths']['/any-dash_underscore/']['get']['tags'] == ['any-dash-underscore']
748+
assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants']
749+
699750

700751
@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
701752
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'})

0 commit comments

Comments
 (0)