From d4e9b6eadb6bc53bce42bf84fdc8b659a21b76ad Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Fri, 7 Feb 2020 01:28:33 +0530 Subject: [PATCH 01/26] add tag generation logic --- rest_framework/schemas/openapi.py | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 9c6610eafa..33bc31ac1d 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -66,11 +66,16 @@ def get_schema(self, request=None, public=False): return schema + # View Inspectors class AutoSchema(ViewInspector): + def __init__(self, tags=None): + self.tags = tags + super().__init__() + request_media_types = [] response_media_types = [] @@ -98,6 +103,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 @@ -564,3 +570,29 @@ def _get_responses(self, path, method): 'description': "" } } + + def get_tags(self, path, method): + # If user have specified tags, use them. + if self.tags: + if isinstance(self.tags, (list, set, tuple)): + return self.tags + if isinstance(self.tags, (dict, OrderedDict)): + return self.tags + raise ValueError('tags must be dict or list.') + + # Extract tag from viewset name + # UserViewSet tags = [User] + # User tags = [User] + if hasattr(self.view, 'action'): + name = self.view.__class__.__name__ + if name.lower().endswith('viewset'): + name = name[:-7] # remove trailing `viewset` from name + return [name] + + # First element of a specific path could be valid tag. This is a fallback solution. + # PUT, PATCH, GET(Retrieve), DELETE: /users/{id}/ tags = [users] + # POST, GET(List): /users/ tags = [users] + if path.startswith('/'): + path = path[1:] + + return [path.split('/')[0]] From bb339f4947c34d82dd89884c26f0914ca5eb3e03 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Fri, 7 Feb 2020 01:28:51 +0530 Subject: [PATCH 02/26] FIX existing test cases --- tests/schemas/test_openapi.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index cfa2e89ef8..34e0eca47e 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -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': '', @@ -166,6 +167,7 @@ def test_path_with_id_parameter(self): 'type': 'string', }, }], + 'tags': ['example'], 'responses': { '200': { 'description': '', From 44c1c25bdef8086a8c47d2f4a2e87fa5cb26e1c6 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 13:14:26 +0530 Subject: [PATCH 03/26] add support for tag objects --- rest_framework/schemas/openapi.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 33bc31ac1d..d49a7e14e4 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -43,6 +43,8 @@ def get_schema(self, request=None, public=False): # Iterate endpoints generating per method path operations. # TODO: …and reference components. paths = {} + tags = [] + processed_views_for_tags = set() _, view_endpoints = self._get_paths_and_endpoints(None if public else request) for path, method, view in view_endpoints: if not self.has_view_permissions(path, method, view): @@ -57,11 +59,16 @@ def get_schema(self, request=None, public=False): paths.setdefault(path, {}) paths[path][method.lower()] = operation + if view.__class__.__name__ not in processed_views_for_tags: + tags.extend(view.schema.get_tag_objects()) + processed_views_for_tags.add(view.__class__.__name__) + # Compile final schema. schema = { 'openapi': '3.0.2', 'info': self.get_info(), 'paths': paths, + 'tags': tags } return schema @@ -73,7 +80,10 @@ def get_schema(self, request=None, public=False): class AutoSchema(ViewInspector): def __init__(self, tags=None): - self.tags = tags + if tags is None: + tags = [] + self._tag_objects = list(filter(lambda tag: isinstance(tag, (dict, OrderedDict)), tags)) + self._tags = list(map(lambda tag: tag['name'] if isinstance(tag, (dict, OrderedDict)) else tag, tags)) super().__init__() request_media_types = [] @@ -103,10 +113,13 @@ 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) + operation['tags'] = self._get_tags(path, method) return operation + def get_tag_objects(self): + return self._tag_objects + def _get_operation_id(self, path, method): """ Compute an operation ID from the model, serializer or view name. @@ -571,14 +584,10 @@ def _get_responses(self, path, method): } } - def get_tags(self, path, method): + def _get_tags(self, path, method): # If user have specified tags, use them. - if self.tags: - if isinstance(self.tags, (list, set, tuple)): - return self.tags - if isinstance(self.tags, (dict, OrderedDict)): - return self.tags - raise ValueError('tags must be dict or list.') + if self._tags: + return self._tags # Extract tag from viewset name # UserViewSet tags = [User] From a5eec911960e743929fb59ed94f0de40c023c582 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 13:14:52 +0530 Subject: [PATCH 04/26] improve tag generation from viewset name --- rest_framework/schemas/openapi.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index d49a7e14e4..8bdbdfe137 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -590,12 +590,14 @@ def _get_tags(self, path, method): return self._tags # Extract tag from viewset name - # UserViewSet tags = [User] + # UserView tags = [User] # User tags = [User] if hasattr(self.view, 'action'): name = self.view.__class__.__name__ - if name.lower().endswith('viewset'): - name = name[:-7] # remove trailing `viewset` from name + if name.endswith('APIView') or name.endswith('ViewSet'): + name = name[:-7] + elif name.endswith('View'): + name = name[:-4] return [name] # First element of a specific path could be valid tag. This is a fallback solution. From ac145a48a4ef820a4c3c3be86b8680d357964466 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 18:08:14 +0530 Subject: [PATCH 05/26] add documentation for tags --- docs/api-guide/schemas.md | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index e33a2a6112..ce9041e8ce 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -215,6 +215,90 @@ 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 for logical grouping `View`s. Each tag name in the list MUST be unique. + +**Django REST Framework generates tags automatically by the following logic:** +1. Extract tag name from `ViewSet`. If `ViewSet` name ends with `ViewSet`, or `View`, it will be truncated from tag name. + + ViewSet Class | Tag Name + ----------------|------------ + User | user + UserView | user + UserViewSet | user + +2. If View is not an instance of ViewSet, generate tag name from the path. Consider the below table to understand more about path-based tag generation. + + Example 1: Consider a user management system. The following table will illustrate the tag generation logic. + + 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 a REST APIs to deal with a branch of a particular restaurant. + + Http Method | Path | Tags + -------------------------------------|----------------------------------------------------|------------------- + PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | [restaurants] + POST, GET(List): | /restaurants/{restaurant_id}/branches/ | [restaurants] + + +**You can override auto-generated tags by passing a list of tags to each `View` by passing `tags` as an argument to the constructor of `AutoSchema`. `tags` argument can be:** +1. list of string. + ```python + class MyView(APIView): + ... + schema = AutoSchema(tags=['tag1', 'tag2']) + ``` +2. list of dict. This adds metadata to a single tag. Each dict can have 3 possible keys: + + Field name | Data type | Required | Description + -------------|-----------|----------|------------------------------------------------------------------------- + name | string | yes | The name of the tag. + description | string | no | A short description for the tag. [CommonMark syntax](https://spec.commonmark.org/) MAY be used for rich text representation. + externalDocs | dict | no | Additional external documentation for this tag. [Click here](https://swagger.io/specification/#externalDocumentationObject) to know more. + + Note: A tag dict with only `name` as a key is logically equivalent to passing a `string` as a tag. + + ```python + class MyView(APIView): + ... + schema = AutoSchema(tags=[ + { + "name": "user" + }, + { + "name": "pet", + "description": "Everything about your Pets" + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "url": "https://example.com", + "description": "Find more info here" + } + }, + ]) + ``` +3. list which is mix of string and strings. + ```python + class MyView(APIView): + ... + schema = AutoSchema(tags=[ + 'user', + { + "name": "order", + "description": "Everything about your Pets" + }, + 'pet' + ]) + ``` + [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 From 1baeb24f8a95cd2e48b7cfb7e7c3e92fd94dbebc Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 18:11:53 +0530 Subject: [PATCH 06/26] fix grammatical error --- docs/api-guide/schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index ce9041e8ce..0fe2186e9e 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -239,7 +239,7 @@ Tags can be used for logical grouping `View`s. Each tag name in the list MUST be POST, GET(List): | /users/ | [users] Example 2: Consider a restaurant management system. The System has restaurants. Each restaurant has branches. - Consider a REST APIs to deal with a branch of a particular restaurant. + Consider REST APIs to deal with a branch of a particular restaurant. Http Method | Path | Tags -------------------------------------|----------------------------------------------------|------------------- From 87444708fa0a3c70f0962030759752f96a27d2d1 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 18:30:58 +0530 Subject: [PATCH 07/26] remove extra line --- rest_framework/schemas/openapi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 8bdbdfe137..440bafa69b 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -73,7 +73,6 @@ def get_schema(self, request=None, public=False): return schema - # View Inspectors From 25f14257080d0511a7a3ece81ad2bb08254c1c75 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 18:31:40 +0530 Subject: [PATCH 08/26] remove APIView name check --- rest_framework/schemas/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 440bafa69b..6abaf1b5de 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -593,7 +593,7 @@ def _get_tags(self, path, method): # User tags = [User] if hasattr(self.view, 'action'): name = self.view.__class__.__name__ - if name.endswith('APIView') or name.endswith('ViewSet'): + if name.endswith('ViewSet'): name = name[:-7] elif name.endswith('View'): name = name[:-4] From 05d8a7b5c913c4510c0e6d2994c23c9ccf76b7c6 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 19:34:24 +0530 Subject: [PATCH 09/26] add ExampleTagsViewSet view --- tests/schemas/views.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/schemas/views.py b/tests/schemas/views.py index e8307ccbd0..ba326ad14e 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -137,3 +137,14 @@ def get(self, *args, **kwargs): url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', ip='192.168.1.1') return Response(serializer.data) + + +class ExampleTagsViewSet(GenericViewSet): + serializer_class = ExampleSerializer + + def retrieve(self, request, *args, **kwargs): + serializer = self.get_serializer(integer=33, string='hello', regex='foo', decimal1=3.55, + decimal2=5.33, email='a@b.co', + url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', + ip='192.168.1.1') + return Response(serializer.data) From 4b4f1c1320a2a655d1720bbe04d641e299d51a93 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 19:35:09 +0530 Subject: [PATCH 10/26] add test cases for tag generation --- tests/schemas/test_openapi.py | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 34e0eca47e..cd38e4a63e 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -698,6 +698,122 @@ def test_serializer_validators(self): assert properties['ip']['type'] == 'string' assert 'format' not in properties['ip'] + def test_overridden_string_tags(self): + class ExampleStringTagsViewSet(views.ExampleTagsViewSet): + schema = AutoSchema(tags=['example1', 'example2']) + + router = routers.SimpleRouter() + router.register('test', ExampleStringTagsViewSet, basename="test") + generator = SchemaGenerator(patterns=router.urls) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/test/{id}/']['get']['tags'] == ['example1', 'example2'] + assert schema['tags'] == [] + + def test_overridden_dict_tags(self): + class ExampleDictTagsViewSet(views.ExampleTagsViewSet): + schema = AutoSchema(tags=[ + { + "name": "user" + }, + { + "name": "pet", + "description": "Everything about your Pets" + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "url": "https://example.com", + "description": "Find more info here" + } + }, + ]) + + router = routers.SimpleRouter() + router.register('test', ExampleDictTagsViewSet, basename="test") + generator = SchemaGenerator(patterns=router.urls) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/test/{id}/']['get']['tags'] == ['user', 'pet', 'store'] + assert schema['tags'] == [ + { + "name": "user" + }, + { + "name": "pet", + "description": "Everything about your Pets" + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "url": "https://example.com", + "description": "Find more info here" + } + }, + ] + + def test_mix_of_string_and_dict_tags(self): + class ExampleMixTagsViewSet(views.ExampleTagsViewSet): + schema = AutoSchema(tags=[ + 'user', + { + "name": "order", + "description": "Everything about your Pets" + }, + 'pet' + ]) + + router = routers.SimpleRouter() + router.register('test', ExampleMixTagsViewSet, basename="test") + generator = SchemaGenerator(patterns=router.urls) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/test/{id}/']['get']['tags'] == ['user', 'order', 'pet'] + assert schema['tags'] == [ + { + "name": "order", + "description": "Everything about your Pets" + } + ] + + def test_auto_generated_viewset_tags(self): + class ExampleViewSet(views.ExampleTagsViewSet): + pass + + class ExampleView(views.ExampleTagsViewSet): + pass + + class Example(views.ExampleTagsViewSet): + pass + + router = routers.SimpleRouter() + router.register('test1', ExampleViewSet, basename="test") + router.register('test2', ExampleView, basename="test") + router.register('test3', Example, basename="test") + + generator = SchemaGenerator(patterns=router.urls) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/test1/{id}/']['get']['tags'] == ['Example'] + assert schema['paths']['/test2/{id}/']['get']['tags'] == ['Example'] + assert schema['paths']['/test3/{id}/']['get']['tags'] == ['Example'] + assert schema['tags'] == [] + + def test_auto_generated_apiview_tags(self): + class RestaurantAPIView(views.ExampleGenericAPIView): + pass + + class BranchAPIView(views.ExampleGenericAPIView): + pass + + url_patterns = [ + url(r'^restaurants/?$', 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']['/restaurants/']['get']['tags'] == ['restaurants'] + assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants'] + assert schema['tags'] == [] + @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'}) From 9c3a632e29886be6ebc60e0a8415a6ea9873407c Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Sun, 9 Feb 2020 19:51:39 +0530 Subject: [PATCH 11/26] minor improvement in documentation --- docs/api-guide/schemas.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 0fe2186e9e..bf19c5425f 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -218,35 +218,38 @@ project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. ### Grouping Operations With Tags -Tags can be used for logical grouping `View`s. Each tag name in the list MUST be unique. +Tags can be used for logical grouping operations. Each tag name in the list MUST be unique. -**Django REST Framework generates tags automatically by the following logic:** +--- +**Django REST Framework generates tags automatically with following logic:** 1. Extract tag name from `ViewSet`. If `ViewSet` name ends with `ViewSet`, or `View`, it will be truncated from tag name. - ViewSet Class | Tag Name + ViewSet Class | Tags ----------------|------------ - User | user - UserView | user + User | User + UserView | User UserViewSet | user -2. If View is not an instance of ViewSet, generate tag name from the path. Consider the below table to understand more about path-based tag generation. +2. If View is not an instance of ViewSet, tag name will be first element from the path. 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] + 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] - +--- **You can override auto-generated tags by passing a list of tags to each `View` by passing `tags` as an argument to the constructor of `AutoSchema`. `tags` argument can be:** 1. list of string. ```python @@ -258,9 +261,9 @@ Tags can be used for logical grouping `View`s. Each tag name in the list MUST be Field name | Data type | Required | Description -------------|-----------|----------|------------------------------------------------------------------------- - name | string | yes | The name of the tag. - description | string | no | A short description for the tag. [CommonMark syntax](https://spec.commonmark.org/) MAY be used for rich text representation. - externalDocs | dict | no | Additional external documentation for this tag. [Click here](https://swagger.io/specification/#externalDocumentationObject) to know more. + name | string | yes | The name of the tag. + description | string | no | A short description for the tag. [CommonMark syntax](https://spec.commonmark.org/) MAY be used for rich text representation. + externalDocs | dict | no | Additional external documentation for this tag. [Click here](https://swagger.io/specification/#externalDocumentationObject) to know more. Note: A tag dict with only `name` as a key is logically equivalent to passing a `string` as a tag. @@ -285,7 +288,7 @@ Tags can be used for logical grouping `View`s. Each tag name in the list MUST be }, ]) ``` -3. list which is mix of string and strings. +3. list which is mix of dicts and strings. ```python class MyView(APIView): ... From b0f11cdd294266cdc2bc3a1151cf48c398aab11f Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Thu, 13 Feb 2020 00:09:19 +0530 Subject: [PATCH 12/26] fix changes given by kevin-brown --- docs/api-guide/schemas.md | 43 +++++++++++++++++++++---------- rest_framework/schemas/openapi.py | 5 ++-- tests/schemas/test_openapi.py | 18 ++++++------- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index bf19c5425f..be2f700da5 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -222,23 +222,30 @@ Tags can be used for logical grouping operations. Each tag name in the list MUST --- **Django REST Framework generates tags automatically with following logic:** -1. Extract tag name from `ViewSet`. If `ViewSet` name ends with `ViewSet`, or `View`, it will be truncated from tag name. - - ViewSet Class | Tags - ----------------|------------ - User | User - UserView | User - UserViewSet | user +1. Extract tag from `ViewSet`. + 1. If `ViewSet` name ends with `ViewSet`, or `View`, remove them. + 2. Convert class name into words & join each word with a space. + + Examples: + + ViewSet Class | Tags + ----------------|------------ + User | ['user'] + UserView | ['user'] + UserViewSet | ['user'] + PascalCaseXYZ | ['pascal case xyz'] + IPAddressView | ['ip address'] -2. If View is not an instance of ViewSet, tag name will be first element from the path. Consider below examples. +2. If View is not an instance of ViewSet, tag name will be first element from the path. Also, any `-` or `_` in path name will be converted as a space. +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] + 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. @@ -246,11 +253,21 @@ Tags can be used for logical grouping operations. Each tag name in the list MUST Http Method | Path | Tags -------------------------------------|----------------------------------------------------|------------------- - PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | [restaurants] - POST, GET(List): | /restaurants/{restaurant_id}/branches/ | [restaurants] + 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'] + --- -**You can override auto-generated tags by passing a list of tags to each `View` by passing `tags` as an argument to the constructor of `AutoSchema`. `tags` argument can be:** +**You can override auto-generated tags by passing `tags` argument to the constructor of `AutoSchema`.** + +**`tags` argument can be a** 1. list of string. ```python class MyView(APIView): diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 6abaf1b5de..a577fe26c4 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -15,6 +15,7 @@ from rest_framework.compat import uritemplate from rest_framework.fields import _UnvalidatedField, empty +from ..utils.formatting import camelcase_to_spaces from .generators import BaseSchemaGenerator from .inspectors import ViewInspector from .utils import get_pk_description, is_list_view @@ -597,7 +598,7 @@ def _get_tags(self, path, method): name = name[:-7] elif name.endswith('View'): name = name[:-4] - return [name] + return [camelcase_to_spaces(name).lower()] # First element of a specific path could be valid tag. This is a fallback solution. # PUT, PATCH, GET(Retrieve), DELETE: /users/{id}/ tags = [users] @@ -605,4 +606,4 @@ def _get_tags(self, path, method): if path.startswith('/'): path = path[1:] - return [path.split('/')[0]] + return [path.split('/')[0].translate(str.maketrans({'-': ' ', '_': ' '}))] diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index cd38e4a63e..f9184d5cf1 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -776,25 +776,25 @@ class ExampleMixTagsViewSet(views.ExampleTagsViewSet): ] def test_auto_generated_viewset_tags(self): - class ExampleViewSet(views.ExampleTagsViewSet): + class ExampleIPViewSet(views.ExampleTagsViewSet): pass - class ExampleView(views.ExampleTagsViewSet): + class ExampleXYZView(views.ExampleTagsViewSet): pass class Example(views.ExampleTagsViewSet): pass router = routers.SimpleRouter() - router.register('test1', ExampleViewSet, basename="test") - router.register('test2', ExampleView, basename="test") + router.register('test1', ExampleIPViewSet, basename="test") + router.register('test2', ExampleXYZView, basename="test") router.register('test3', Example, basename="test") generator = SchemaGenerator(patterns=router.urls) schema = generator.get_schema(request=create_request('/')) - assert schema['paths']['/test1/{id}/']['get']['tags'] == ['Example'] - assert schema['paths']['/test2/{id}/']['get']['tags'] == ['Example'] - assert schema['paths']['/test3/{id}/']['get']['tags'] == ['Example'] + assert schema['paths']['/test1/{id}/']['get']['tags'] == ['example ip'] + assert schema['paths']['/test2/{id}/']['get']['tags'] == ['example xyz'] + assert schema['paths']['/test3/{id}/']['get']['tags'] == ['example'] assert schema['tags'] == [] def test_auto_generated_apiview_tags(self): @@ -805,12 +805,12 @@ class BranchAPIView(views.ExampleGenericAPIView): pass url_patterns = [ - url(r'^restaurants/?$', RestaurantAPIView.as_view()), + 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']['/restaurants/']['get']['tags'] == ['restaurants'] + assert schema['paths']['/any-dash_underscore/']['get']['tags'] == ['any dash underscore'] assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants'] assert schema['tags'] == [] From 10cdd2bde3917ef69a0a6f8fca6d9fbbf1518bd0 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Thu, 13 Feb 2020 00:19:10 +0530 Subject: [PATCH 13/26] improve documentation --- docs/api-guide/schemas.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index be2f700da5..4405a3bf3f 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -218,13 +218,13 @@ project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. ### Grouping Operations With Tags -Tags can be used for logical grouping operations. Each tag name in the list MUST be unique. +Tags can be used to group logical operations. Each tag name in the list MUST be unique. --- **Django REST Framework generates tags automatically with following logic:** 1. Extract tag from `ViewSet`. - 1. If `ViewSet` name ends with `ViewSet`, or `View`, remove them. - 2. Convert class name into words & join each word with a space. + 1. If `ViewSet` name ends with `ViewSet`, or `View`; remove it. + 2. Convert class name into lowercase words & join each word with a space. Examples: From ee97de3269461b805faebea1c37a5bfbcdb60a82 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Thu, 13 Feb 2020 00:21:22 +0530 Subject: [PATCH 14/26] improve documentation --- docs/api-guide/schemas.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 4405a3bf3f..00c7e78077 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -224,7 +224,7 @@ Tags can be used to group logical operations. Each tag name in the list MUST be **Django REST Framework generates tags automatically with following logic:** 1. Extract tag from `ViewSet`. 1. If `ViewSet` name ends with `ViewSet`, or `View`; remove it. - 2. Convert class name into lowercase words & join each word with a space. + 2. Convert class name into lowercase words & join each word using a space. Examples: @@ -236,7 +236,7 @@ Tags can be used to group logical operations. Each tag name in the list MUST be PascalCaseXYZ | ['pascal case xyz'] IPAddressView | ['ip address'] -2. If View is not an instance of ViewSet, tag name will be first element from the path. Also, any `-` or `_` in path name will be converted as a space. +2. If View is not an instance of ViewSet, tag name will be first element from the path. Also, any `-` or `_` in path name will be replaced by a space. Consider below examples. Example 1: Consider a user management system. The following table will illustrate the tag generation logic. From 31a1eb14eb58064821506a361dcf9c12b9b9d6c9 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Thu, 13 Feb 2020 00:26:55 +0530 Subject: [PATCH 15/26] add test case for tag generation from view-set --- tests/schemas/test_openapi.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index f9184d5cf1..6fea371f35 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -785,16 +785,22 @@ class ExampleXYZView(views.ExampleTagsViewSet): class Example(views.ExampleTagsViewSet): pass + class PascalCaseXYZTestIp(views.ExampleTagsViewSet): + pass + router = routers.SimpleRouter() - router.register('test1', ExampleIPViewSet, basename="test") - router.register('test2', ExampleXYZView, basename="test") - router.register('test3', Example, basename="test") + router.register('test1', ExampleIPViewSet, basename="test1") + router.register('test2', ExampleXYZView, basename="test2") + router.register('test3', Example, basename="test3") + router.register('test4', PascalCaseXYZTestIp, basename="test4") generator = SchemaGenerator(patterns=router.urls) schema = generator.get_schema(request=create_request('/')) assert schema['paths']['/test1/{id}/']['get']['tags'] == ['example ip'] assert schema['paths']['/test2/{id}/']['get']['tags'] == ['example xyz'] assert schema['paths']['/test3/{id}/']['get']['tags'] == ['example'] + assert schema['paths']['/test4/{id}/']['get']['tags'] == ['pascal case xyz test ip'] + assert schema['tags'] == [] def test_auto_generated_apiview_tags(self): From 56178ed2a47f1010a4f35d7587ff9d62710b1545 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 01:37:06 +0530 Subject: [PATCH 16/26] remove support for dict tags --- rest_framework/schemas/openapi.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index a577fe26c4..b4aa658e55 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -44,8 +44,6 @@ def get_schema(self, request=None, public=False): # Iterate endpoints generating per method path operations. # TODO: …and reference components. paths = {} - tags = [] - processed_views_for_tags = set() _, view_endpoints = self._get_paths_and_endpoints(None if public else request) for path, method, view in view_endpoints: if not self.has_view_permissions(path, method, view): @@ -60,16 +58,11 @@ def get_schema(self, request=None, public=False): paths.setdefault(path, {}) paths[path][method.lower()] = operation - if view.__class__.__name__ not in processed_views_for_tags: - tags.extend(view.schema.get_tag_objects()) - processed_views_for_tags.add(view.__class__.__name__) - # Compile final schema. schema = { 'openapi': '3.0.2', 'info': self.get_info(), 'paths': paths, - 'tags': tags } return schema @@ -80,10 +73,9 @@ def get_schema(self, request=None, public=False): class AutoSchema(ViewInspector): def __init__(self, tags=None): - if tags is None: - tags = [] - self._tag_objects = list(filter(lambda tag: isinstance(tag, (dict, OrderedDict)), tags)) - self._tags = list(map(lambda tag: tag['name'] if isinstance(tag, (dict, OrderedDict)) else tag, tags)) + if tags and not all(isinstance(tag, str) for tag in tags): + raise ValueError('tags must be a list of string.') + self._tags = tags super().__init__() request_media_types = [] @@ -117,9 +109,6 @@ def get_operation(self, path, method): return operation - def get_tag_objects(self): - return self._tag_objects - def _get_operation_id(self, path, method): """ Compute an operation ID from the model, serializer or view name. From 912f22a46a01b64842bde42906f1cc0746994672 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 01:38:37 +0530 Subject: [PATCH 17/26] change tag name style to url path style --- rest_framework/schemas/openapi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index b4aa658e55..956d4a4d8b 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -587,7 +587,7 @@ def _get_tags(self, path, method): name = name[:-7] elif name.endswith('View'): name = name[:-4] - return [camelcase_to_spaces(name).lower()] + return [camelcase_to_spaces(name).lower().replace(' ', '-')] # First element of a specific path could be valid tag. This is a fallback solution. # PUT, PATCH, GET(Retrieve), DELETE: /users/{id}/ tags = [users] @@ -595,4 +595,4 @@ def _get_tags(self, path, method): if path.startswith('/'): path = path[1:] - return [path.split('/')[0].translate(str.maketrans({'-': ' ', '_': ' '}))] + return [path.split('/')[0].replace('_', '-')] From cc2a8a5a35c63ff1a2d3b9977a148fdc7e368ba2 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 01:44:42 +0530 Subject: [PATCH 18/26] remove test cases for tag objects --- tests/schemas/test_openapi.py | 80 +++-------------------------------- 1 file changed, 5 insertions(+), 75 deletions(-) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 6fea371f35..e9f8515332 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -698,7 +698,7 @@ def test_serializer_validators(self): assert properties['ip']['type'] == 'string' assert 'format' not in properties['ip'] - def test_overridden_string_tags(self): + def test_overridden_tags(self): class ExampleStringTagsViewSet(views.ExampleTagsViewSet): schema = AutoSchema(tags=['example1', 'example2']) @@ -707,73 +707,6 @@ class ExampleStringTagsViewSet(views.ExampleTagsViewSet): generator = SchemaGenerator(patterns=router.urls) schema = generator.get_schema(request=create_request('/')) assert schema['paths']['/test/{id}/']['get']['tags'] == ['example1', 'example2'] - assert schema['tags'] == [] - - def test_overridden_dict_tags(self): - class ExampleDictTagsViewSet(views.ExampleTagsViewSet): - schema = AutoSchema(tags=[ - { - "name": "user" - }, - { - "name": "pet", - "description": "Everything about your Pets" - }, - { - "name": "store", - "description": "Access to Petstore orders", - "externalDocs": { - "url": "https://example.com", - "description": "Find more info here" - } - }, - ]) - - router = routers.SimpleRouter() - router.register('test', ExampleDictTagsViewSet, basename="test") - generator = SchemaGenerator(patterns=router.urls) - schema = generator.get_schema(request=create_request('/')) - assert schema['paths']['/test/{id}/']['get']['tags'] == ['user', 'pet', 'store'] - assert schema['tags'] == [ - { - "name": "user" - }, - { - "name": "pet", - "description": "Everything about your Pets" - }, - { - "name": "store", - "description": "Access to Petstore orders", - "externalDocs": { - "url": "https://example.com", - "description": "Find more info here" - } - }, - ] - - def test_mix_of_string_and_dict_tags(self): - class ExampleMixTagsViewSet(views.ExampleTagsViewSet): - schema = AutoSchema(tags=[ - 'user', - { - "name": "order", - "description": "Everything about your Pets" - }, - 'pet' - ]) - - router = routers.SimpleRouter() - router.register('test', ExampleMixTagsViewSet, basename="test") - generator = SchemaGenerator(patterns=router.urls) - schema = generator.get_schema(request=create_request('/')) - assert schema['paths']['/test/{id}/']['get']['tags'] == ['user', 'order', 'pet'] - assert schema['tags'] == [ - { - "name": "order", - "description": "Everything about your Pets" - } - ] def test_auto_generated_viewset_tags(self): class ExampleIPViewSet(views.ExampleTagsViewSet): @@ -796,12 +729,10 @@ class PascalCaseXYZTestIp(views.ExampleTagsViewSet): generator = SchemaGenerator(patterns=router.urls) schema = generator.get_schema(request=create_request('/')) - assert schema['paths']['/test1/{id}/']['get']['tags'] == ['example ip'] - assert schema['paths']['/test2/{id}/']['get']['tags'] == ['example xyz'] + assert schema['paths']['/test1/{id}/']['get']['tags'] == ['example-ip'] + assert schema['paths']['/test2/{id}/']['get']['tags'] == ['example-xyz'] assert schema['paths']['/test3/{id}/']['get']['tags'] == ['example'] - assert schema['paths']['/test4/{id}/']['get']['tags'] == ['pascal case xyz test ip'] - - assert schema['tags'] == [] + assert schema['paths']['/test4/{id}/']['get']['tags'] == ['pascal-case-xyz-test-ip'] def test_auto_generated_apiview_tags(self): class RestaurantAPIView(views.ExampleGenericAPIView): @@ -816,9 +747,8 @@ class BranchAPIView(views.ExampleGenericAPIView): ] 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']['/any-dash_underscore/']['get']['tags'] == ['any-dash-underscore'] assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants'] - assert schema['tags'] == [] @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') From 8d3051d3a3f8ab90c41117b3075eda6fa4fcbfd9 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 22:12:36 +0530 Subject: [PATCH 19/26] add better example in comments --- rest_framework/schemas/openapi.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 956d4a4d8b..ce75776837 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -579,8 +579,9 @@ def _get_tags(self, path, method): return self._tags # Extract tag from viewset name - # UserView tags = [User] - # User tags = [User] + # UserProfileViewSet tags = [user-profile] + # UserProfileView tags = [user-profile] + # UserProfile tags = [user-profile] if hasattr(self.view, 'action'): name = self.view.__class__.__name__ if name.endswith('ViewSet'): @@ -590,8 +591,8 @@ def _get_tags(self, path, method): return [camelcase_to_spaces(name).lower().replace(' ', '-')] # First element of a specific path could be valid tag. This is a fallback solution. - # PUT, PATCH, GET(Retrieve), DELETE: /users/{id}/ tags = [users] - # POST, GET(List): /users/ tags = [users] + # 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:] From 4229234318ee4cdc86418a398b06177f50d22701 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 22:13:47 +0530 Subject: [PATCH 20/26] sync documentation with implementation. --- docs/api-guide/schemas.md | 77 +++++++++------------------------------ 1 file changed, 18 insertions(+), 59 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 00c7e78077..f060c95e16 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -221,10 +221,11 @@ project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. Tags can be used to group logical operations. Each tag name in the list MUST be unique. --- -**Django REST Framework generates tags automatically with following logic:** +#### Django REST Framework generates tags automatically with following logic: + 1. Extract tag from `ViewSet`. 1. If `ViewSet` name ends with `ViewSet`, or `View`; remove it. - 2. Convert class name into lowercase words & join each word using a space. + 2. Convert class name into lowercase words & join each word using a `-`(dash). Examples: @@ -233,10 +234,10 @@ Tags can be used to group logical operations. Each tag name in the list MUST be User | ['user'] UserView | ['user'] UserViewSet | ['user'] - PascalCaseXYZ | ['pascal case xyz'] - IPAddressView | ['ip address'] + PascalCaseXYZ | ['pascal-case-xyz'] + IPAddressView | ['ip-address'] -2. If View is not an instance of ViewSet, tag name will be first element from the path. Also, any `-` or `_` in path name will be replaced by a space. +2. If View is not an instance of ViewSet, 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. @@ -260,65 +261,23 @@ Consider below examples. Http Method | Path | Tags -------------------------------------|-------------------------|------------- - PUT, PATCH, GET(Retrieve), DELETE | /order-items/{id}/ | ['order items'] - POST, GET(List) | /order-items/ | ['order items'] + PUT, PATCH, GET(Retrieve), DELETE | /order_items/{id}/ | ['order-items'] + POST, GET(List) | /order_items/ | ['order-items'] --- -**You can override auto-generated tags by passing `tags` argument to the constructor of `AutoSchema`.** - -**`tags` argument can be a** -1. list of string. - ```python - class MyView(APIView): - ... - schema = AutoSchema(tags=['tag1', 'tag2']) - ``` -2. list of dict. This adds metadata to a single tag. Each dict can have 3 possible keys: - - Field name | Data type | Required | Description - -------------|-----------|----------|------------------------------------------------------------------------- - name | string | yes | The name of the tag. - description | string | no | A short description for the tag. [CommonMark syntax](https://spec.commonmark.org/) MAY be used for rich text representation. - externalDocs | dict | no | Additional external documentation for this tag. [Click here](https://swagger.io/specification/#externalDocumentationObject) to know more. - - Note: A tag dict with only `name` as a key is logically equivalent to passing a `string` as a tag. +#### 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 of string. +```python +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.views import APIView - ```python - class MyView(APIView): - ... - schema = AutoSchema(tags=[ - { - "name": "user" - }, - { - "name": "pet", - "description": "Everything about your Pets" - }, - { - "name": "store", - "description": "Access to Petstore orders", - "externalDocs": { - "url": "https://example.com", - "description": "Find more info here" - } - }, - ]) - ``` -3. list which is mix of dicts and strings. - ```python - class MyView(APIView): - ... - schema = AutoSchema(tags=[ - 'user', - { - "name": "order", - "description": "Everything about your Pets" - }, - 'pet' - ]) - ``` +class MyView(APIView): + schema = AutoSchema(tags=['tag1', 'tag2']) + ... +``` [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 From d77afd56a824ca1aaab3ced889b377d044251e87 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 22:18:12 +0530 Subject: [PATCH 21/26] improve documentation --- docs/api-guide/schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index f060c95e16..2f0ba7f2e9 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -267,7 +267,7 @@ Consider below examples. --- #### 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 of string. +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 From 22da4772c8140d4b89eca350a1b0ed816034e564 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 22:20:32 +0530 Subject: [PATCH 22/26] change _get_tags to get_tags --- rest_framework/schemas/openapi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index ce75776837..6317dea241 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -105,7 +105,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) + operation['tags'] = self.get_tags(path, method) return operation @@ -573,7 +573,7 @@ def _get_responses(self, path, method): } } - def _get_tags(self, path, method): + def get_tags(self, path, method): # If user have specified tags, use them. if self._tags: return self._tags From 95831b5be1bf0804d82d25f8d5cb809d4aff43f5 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 22:49:45 +0530 Subject: [PATCH 23/26] add guidance for overriding get_tags method --- docs/api-guide/schemas.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 2f0ba7f2e9..e2d6b20608 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -221,7 +221,7 @@ project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. Tags can be used to group logical operations. Each tag name in the list MUST be unique. --- -#### Django REST Framework generates tags automatically with following logic: +#### Django REST Framework generates tags automatically with the following logic: 1. Extract tag from `ViewSet`. 1. If `ViewSet` name ends with `ViewSet`, or `View`; remove it. @@ -277,6 +277,32 @@ class MyView(APIView): ... ``` +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 From f438f14d13feaf3cd68386609aaefc4e10745185 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 23:18:48 +0530 Subject: [PATCH 24/26] add test case for method override use case --- tests/schemas/test_openapi.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index e9f8515332..c5322cdf58 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -708,6 +708,28 @@ class ExampleStringTagsViewSet(views.ExampleTagsViewSet): schema = generator.get_schema(request=create_request('/')) assert schema['paths']['/test/{id}/']['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_viewset_tags(self): class ExampleIPViewSet(views.ExampleTagsViewSet): pass From 48c02dd53a89c399b6aef229776b1f77c079f94f Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Wed, 19 Feb 2020 23:22:05 +0530 Subject: [PATCH 25/26] improve error message --- rest_framework/schemas/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 6317dea241..5f6e37b36c 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -74,7 +74,7 @@ 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 of string.') + raise ValueError('tags must be a list or tuple of string.') self._tags = tags super().__init__() From 64a4828a067d899147e65a099f376ec836702673 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Thu, 20 Feb 2020 18:14:28 +0530 Subject: [PATCH 26/26] remove tag generation from viewset --- docs/api-guide/schemas.md | 60 ++++++++++++------------------- rest_framework/schemas/openapi.py | 13 ------- tests/schemas/test_openapi.py | 37 ++++--------------- tests/schemas/views.py | 11 ------ 4 files changed, 29 insertions(+), 92 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index e2d6b20608..4df135a9f7 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -223,46 +223,32 @@ Tags can be used to group logical operations. Each tag name in the list MUST be --- #### Django REST Framework generates tags automatically with the following logic: -1. Extract tag from `ViewSet`. - 1. If `ViewSet` name ends with `ViewSet`, or `View`; remove it. - 2. Convert class name into lowercase words & join each word using a `-`(dash). - - Examples: - - ViewSet Class | Tags - ----------------|------------ - User | ['user'] - UserView | ['user'] - UserViewSet | ['user'] - PascalCaseXYZ | ['pascal-case-xyz'] - IPAddressView | ['ip-address'] - -2. If View is not an instance of ViewSet, tag name will be first element from the path. Also, any `_` in path name will be replaced by a `-`. +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` +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'] +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'] --- diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 5f6e37b36c..5277f17a61 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -15,7 +15,6 @@ from rest_framework.compat import uritemplate from rest_framework.fields import _UnvalidatedField, empty -from ..utils.formatting import camelcase_to_spaces from .generators import BaseSchemaGenerator from .inspectors import ViewInspector from .utils import get_pk_description, is_list_view @@ -578,18 +577,6 @@ def get_tags(self, path, method): if self._tags: return self._tags - # Extract tag from viewset name - # UserProfileViewSet tags = [user-profile] - # UserProfileView tags = [user-profile] - # UserProfile tags = [user-profile] - if hasattr(self.view, 'action'): - name = self.view.__class__.__name__ - if name.endswith('ViewSet'): - name = name[:-7] - elif name.endswith('View'): - name = name[:-4] - return [camelcase_to_spaces(name).lower().replace(' ', '-')] - # 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] diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index c5322cdf58..7f73c8c300 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -699,14 +699,15 @@ def test_serializer_validators(self): assert 'format' not in properties['ip'] def test_overridden_tags(self): - class ExampleStringTagsViewSet(views.ExampleTagsViewSet): + class ExampleStringTagsViewSet(views.ExampleGenericAPIView): schema = AutoSchema(tags=['example1', 'example2']) - router = routers.SimpleRouter() - router.register('test', ExampleStringTagsViewSet, basename="test") - generator = SchemaGenerator(patterns=router.urls) + url_patterns = [ + url(r'^test/?$', ExampleStringTagsViewSet.as_view()), + ] + generator = SchemaGenerator(patterns=url_patterns) schema = generator.get_schema(request=create_request('/')) - assert schema['paths']['/test/{id}/']['get']['tags'] == ['example1', 'example2'] + assert schema['paths']['/test/']['get']['tags'] == ['example1', 'example2'] def test_overridden_get_tags_method(self): class MySchema(AutoSchema): @@ -730,32 +731,6 @@ class ExampleStringTagsViewSet(views.ExampleGenericViewSet): assert schema['paths']['/example/new/']['get']['tags'] == ['tag1', 'tag2'] assert schema['paths']['/example/old/']['get']['tags'] == ['tag2', 'tag3'] - def test_auto_generated_viewset_tags(self): - class ExampleIPViewSet(views.ExampleTagsViewSet): - pass - - class ExampleXYZView(views.ExampleTagsViewSet): - pass - - class Example(views.ExampleTagsViewSet): - pass - - class PascalCaseXYZTestIp(views.ExampleTagsViewSet): - pass - - router = routers.SimpleRouter() - router.register('test1', ExampleIPViewSet, basename="test1") - router.register('test2', ExampleXYZView, basename="test2") - router.register('test3', Example, basename="test3") - router.register('test4', PascalCaseXYZTestIp, basename="test4") - - generator = SchemaGenerator(patterns=router.urls) - schema = generator.get_schema(request=create_request('/')) - assert schema['paths']['/test1/{id}/']['get']['tags'] == ['example-ip'] - assert schema['paths']['/test2/{id}/']['get']['tags'] == ['example-xyz'] - assert schema['paths']['/test3/{id}/']['get']['tags'] == ['example'] - assert schema['paths']['/test4/{id}/']['get']['tags'] == ['pascal-case-xyz-test-ip'] - def test_auto_generated_apiview_tags(self): class RestaurantAPIView(views.ExampleGenericAPIView): pass diff --git a/tests/schemas/views.py b/tests/schemas/views.py index ba326ad14e..e8307ccbd0 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -137,14 +137,3 @@ def get(self, *args, **kwargs): url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', ip='192.168.1.1') return Response(serializer.data) - - -class ExampleTagsViewSet(GenericViewSet): - serializer_class = ExampleSerializer - - def retrieve(self, request, *args, **kwargs): - serializer = self.get_serializer(integer=33, string='hello', regex='foo', decimal1=3.55, - decimal2=5.33, email='a@b.co', - url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', - ip='192.168.1.1') - return Response(serializer.data)