Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Possible solution for tags generation #7184

Merged
merged 26 commits into from
Feb 28, 2020
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d4e9b6e
add tag generation logic
dhaval-mehta Feb 6, 2020
bb339f4
FIX existing test cases
dhaval-mehta Feb 6, 2020
44c1c25
add support for tag objects
dhaval-mehta Feb 9, 2020
a5eec91
improve tag generation from viewset name
dhaval-mehta Feb 9, 2020
ac145a4
add documentation for tags
dhaval-mehta Feb 9, 2020
1baeb24
fix grammatical error
dhaval-mehta Feb 9, 2020
8744470
remove extra line
dhaval-mehta Feb 9, 2020
25f1425
remove APIView name check
dhaval-mehta Feb 9, 2020
05d8a7b
add ExampleTagsViewSet view
dhaval-mehta Feb 9, 2020
4b4f1c1
add test cases for tag generation
dhaval-mehta Feb 9, 2020
9c3a632
minor improvement in documentation
dhaval-mehta Feb 9, 2020
b0f11cd
fix changes given by kevin-brown
dhaval-mehta Feb 12, 2020
10cdd2b
improve documentation
dhaval-mehta Feb 12, 2020
ee97de3
improve documentation
dhaval-mehta Feb 12, 2020
31a1eb1
add test case for tag generation from view-set
dhaval-mehta Feb 12, 2020
56178ed
remove support for dict tags
dhaval-mehta Feb 18, 2020
912f22a
change tag name style to url path style
dhaval-mehta Feb 18, 2020
cc2a8a5
remove test cases for tag objects
dhaval-mehta Feb 18, 2020
8d3051d
add better example in comments
dhaval-mehta Feb 19, 2020
4229234
sync documentation with implementation.
dhaval-mehta Feb 19, 2020
d77afd5
improve documentation
dhaval-mehta Feb 19, 2020
22da477
change _get_tags to get_tags
dhaval-mehta Feb 19, 2020
95831b5
add guidance for overriding get_tags method
dhaval-mehta Feb 19, 2020
f438f14
add test case for method override use case
dhaval-mehta Feb 19, 2020
48c02dd
improve error message
dhaval-mehta Feb 19, 2020
64a4828
remove tag generation from viewset
dhaval-mehta Feb 20, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/api-guide/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,110 @@ 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 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.

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 `-` or `_` in path name will be replaced by a space.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we ought to just use the path style as the default case?

I think that'd be more obvious, more consistent across a code base, and a little more simple.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I will implement the path style as the default case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think the default implementation should use the path. Folks can pass tags, or subclass for something cleverer.

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']


---
**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'])
carltongibson marked this conversation as resolved.
Show resolved Hide resolved
```
2. list of dict. This adds metadata to a single tag. Each dict can have 3 possible keys:
carltongibson marked this conversation as resolved.
Show resolved Hide resolved

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 dicts 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
43 changes: 43 additions & 0 deletions rest_framework/schemas/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,6 +44,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):
Expand All @@ -57,11 +60,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__)
carltongibson marked this conversation as resolved.
Show resolved Hide resolved

# Compile final schema.
schema = {
'openapi': '3.0.2',
'info': self.get_info(),
'paths': paths,
'tags': tags
}

return schema
Expand All @@ -71,6 +79,13 @@ 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))
super().__init__()

request_media_types = []
response_media_types = []

Expand Down Expand Up @@ -98,9 +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)

return operation

def get_tag_objects(self):
carltongibson marked this conversation as resolved.
Show resolved Hide resolved
return self._tag_objects

def _get_operation_id(self, path, method):
"""
Compute an operation ID from the model, serializer or view name.
Expand Down Expand Up @@ -564,3 +583,27 @@ def _get_responses(self, path, method):
'description': ""
}
}

def _get_tags(self, path, method):
carltongibson marked this conversation as resolved.
Show resolved Hide resolved
# If user have specified tags, use them.
if self._tags:
return self._tags

# Extract tag from viewset name
# UserView tags = [User]
# User tags = [User]
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()]

# 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].translate(str.maketrans({'-': ' ', '_': ' '}))]
124 changes: 124 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,128 @@ 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 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']

assert schema['tags'] == []

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']
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'})
Expand Down
11 changes: 11 additions & 0 deletions tests/schemas/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='[email protected]',
url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1',
ip='192.168.1.1')
return Response(serializer.data)