Skip to content

Commit

Permalink
Added OpenAPI tags to schemas. (encode#7184)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhaval-mehta authored and sigvef committed Dec 3, 2022
1 parent 23db62f commit efac4f5
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 0 deletions.
75 changes: 75 additions & 0 deletions docs/api-guide/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,81 @@ This also applies to extra actions for `ViewSet`s:
If you wish to provide a base `AutoSchema` subclass to be used throughout your
project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately.


### Grouping Operations With Tags

Tags can be used to group logical operations. Each tag name in the list MUST be unique.

---
#### Django REST Framework generates tags automatically with the following logic:

Tag name will be first element from the path. Also, any `_` in path name will be replaced by a `-`.
Consider below examples.

Example 1: Consider a user management system. The following table will illustrate the tag generation logic.
Here first element from the paths is: `users`. Hence tag wil be `users`

Http Method | Path | Tags
-------------------------------------|-------------------|-------------
PUT, PATCH, GET(Retrieve), DELETE | /users/{id}/ | ['users']
POST, GET(List) | /users/ | ['users']

Example 2: Consider a restaurant management system. The System has restaurants. Each restaurant has branches.
Consider REST APIs to deal with a branch of a particular restaurant.
Here first element from the paths is: `restaurants`. Hence tag wil be `restaurants`.

Http Method | Path | Tags
-------------------------------------|----------------------------------------------------|-------------------
PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | ['restaurants']
POST, GET(List): | /restaurants/{restaurant_id}/branches/ | ['restaurants']

Example 3: Consider Order items for an e commerce company.

Http Method | Path | Tags
-------------------------------------|-------------------------|-------------
PUT, PATCH, GET(Retrieve), DELETE | /order_items/{id}/ | ['order-items']
POST, GET(List) | /order_items/ | ['order-items']


---
#### Overriding auto generated tags:
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.
```python
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.views import APIView

class MyView(APIView):
schema = AutoSchema(tags=['tag1', 'tag2'])
...
```

If you need more customization, you can override the `get_tags` method of `AutoSchema` class. Consider the following example:

```python
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.views import APIView

class MySchema(AutoSchema):
...
def get_tags(self, path, method):
if method == 'POST':
tags = ['tag1', 'tag2']
elif method == 'GET':
tags = ['tag2', 'tag3']
elif path == '/example/path/':
tags = ['tag3', 'tag4']
else:
tags = ['tag5', 'tag6', 'tag7']

return tags

class MyView(APIView):
schema = MySchema()
...
```


[openapi]: https://github.com/OAI/OpenAPI-Specification
[openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions
[openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
[openapi-tags]: https://swagger.io/specification/#tagObject
20 changes: 20 additions & 0 deletions rest_framework/schemas/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ def get_schema(self, request=None, public=False):

class AutoSchema(ViewInspector):

def __init__(self, tags=None):
if tags and not all(isinstance(tag, str) for tag in tags):
raise ValueError('tags must be a list or tuple of string.')
self._tags = tags
super().__init__()

request_media_types = []
response_media_types = []

Expand Down Expand Up @@ -98,6 +104,7 @@ def get_operation(self, path, method):
if request_body:
operation['requestBody'] = request_body
operation['responses'] = self._get_responses(path, method)
operation['tags'] = self.get_tags(path, method)

return operation

Expand Down Expand Up @@ -564,3 +571,16 @@ def _get_responses(self, path, method):
'description': ""
}
}

def get_tags(self, path, method):
# If user have specified tags, use them.
if self._tags:
return self._tags

# First element of a specific path could be valid tag. This is a fallback solution.
# PUT, PATCH, GET(Retrieve), DELETE: /user_profile/{id}/ tags = [user-profile]
# POST, GET(List): /user_profile/ tags = [user-profile]
if path.startswith('/'):
path = path[1:]

return [path.split('/')[0].replace('_', '-')]
51 changes: 51 additions & 0 deletions tests/schemas/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def test_path_without_parameters(self):
'operationId': 'listDocStringExamples',
'description': 'A description of my GET operation.',
'parameters': [],
'tags': ['example'],
'responses': {
'200': {
'description': '',
Expand Down Expand Up @@ -166,6 +167,7 @@ def test_path_with_id_parameter(self):
'type': 'string',
},
}],
'tags': ['example'],
'responses': {
'200': {
'description': '',
Expand Down Expand Up @@ -696,6 +698,55 @@ def test_serializer_validators(self):
assert properties['ip']['type'] == 'string'
assert 'format' not in properties['ip']

def test_overridden_tags(self):
class ExampleStringTagsViewSet(views.ExampleGenericAPIView):
schema = AutoSchema(tags=['example1', 'example2'])

url_patterns = [
url(r'^test/?$', ExampleStringTagsViewSet.as_view()),
]
generator = SchemaGenerator(patterns=url_patterns)
schema = generator.get_schema(request=create_request('/'))
assert schema['paths']['/test/']['get']['tags'] == ['example1', 'example2']

def test_overridden_get_tags_method(self):
class MySchema(AutoSchema):
def get_tags(self, path, method):
if path.endswith('/new/'):
tags = ['tag1', 'tag2']
elif path.endswith('/old/'):
tags = ['tag2', 'tag3']
else:
tags = ['tag4', 'tag5']

return tags

class ExampleStringTagsViewSet(views.ExampleGenericViewSet):
schema = MySchema()

router = routers.SimpleRouter()
router.register('example', ExampleStringTagsViewSet, basename="example")
generator = SchemaGenerator(patterns=router.urls)
schema = generator.get_schema(request=create_request('/'))
assert schema['paths']['/example/new/']['get']['tags'] == ['tag1', 'tag2']
assert schema['paths']['/example/old/']['get']['tags'] == ['tag2', 'tag3']

def test_auto_generated_apiview_tags(self):
class RestaurantAPIView(views.ExampleGenericAPIView):
pass

class BranchAPIView(views.ExampleGenericAPIView):
pass

url_patterns = [
url(r'^any-dash_underscore/?$', RestaurantAPIView.as_view()),
url(r'^restaurants/branches/?$', BranchAPIView.as_view())
]
generator = SchemaGenerator(patterns=url_patterns)
schema = generator.get_schema(request=create_request('/'))
assert schema['paths']['/any-dash_underscore/']['get']['tags'] == ['any-dash-underscore']
assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants']


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

0 comments on commit efac4f5

Please sign in to comment.