From 2a5c2f3f701cca6f531c5e12790a2d63b0d6b4fd Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Fri, 28 Feb 2020 16:36:03 +0530 Subject: [PATCH] Added OpenAPI tags to schemas. (#7184) --- docs/api-guide/schemas.md | 75 +++++++++++++++++++++++++++++++ rest_framework/schemas/openapi.py | 20 +++++++++ tests/schemas/test_openapi.py | 51 +++++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 91c2bbabf..2e5ffc79b 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -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 diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 9c6610eaf..5277f17a6 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -71,6 +71,12 @@ class SchemaGenerator(BaseSchemaGenerator): 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 = [] @@ -98,6 +104,7 @@ class AutoSchema(ViewInspector): if request_body: operation['requestBody'] = request_body operation['responses'] = self._get_responses(path, method) + operation['tags'] = self.get_tags(path, method) return operation @@ -564,3 +571,16 @@ class AutoSchema(ViewInspector): '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('_', '-')] diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index cfa2e89ef..7f73c8c30 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -126,6 +126,7 @@ class TestOperationIntrospection(TestCase): 'operationId': 'listDocStringExamples', 'description': 'A description of my GET operation.', 'parameters': [], + 'tags': ['example'], 'responses': { '200': { 'description': '', @@ -166,6 +167,7 @@ class TestOperationIntrospection(TestCase): 'type': 'string', }, }], + 'tags': ['example'], 'responses': { '200': { 'description': '', @@ -696,6 +698,55 @@ class TestOperationIntrospection(TestCase): 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'})