Skip to content

Generate components for OpenAPI schemas. #7124

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

Merged
merged 1 commit into from
Mar 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
63 changes: 63 additions & 0 deletions docs/api-guide/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,65 @@ class CustomSchema(AutoSchema):
def get_operation_id(self, path, method):
pass

class MyView(APIView):
schema = AutoSchema(component_name="Ulysses")
```

### Components

Since DRF 3.12, Schema uses the [OpenAPI Components](openapi-components). This method defines components in the schema and [references them](openapi-reference) inside request and response objects. By default, the component's name is deduced from the Serializer's name.

Using OpenAPI's components provides the following advantages:
* The schema is more readable and lightweight.
* If you use the schema to generate an SDK (using [openapi-generator](openapi-generator) or [swagger-codegen](swagger-codegen)). The generator can name your SDK's models.

### Handling component's schema errors

You may get the following error while generating the schema:
```
"Serializer" is an invalid class name for schema generation.
Serializer's class name should be unique and explicit. e.g. "ItemSerializer".
```

This error occurs when the Serializer name is "Serializer". You should choose a component's name unique across your schema and different than "Serializer".

You may also get the following warning:
```
Schema component "ComponentName" has been overriden with a different value.
```

This warning occurs when different components have the same name in one schema. Your component name should be unique across your project. This is likely an error that may lead to an invalid schema.

You have two ways to solve the previous issues:
* You can rename your serializer with a unique name and another name than "Serializer".
* You can set the `component_name` kwarg parameter of the AutoSchema constructor (see below).
* You can override the `get_component_name` method of the AutoSchema class (see below).

#### Set a custom component's name for your view

To override the component's name in your view, you can use the `component_name` parameter of the AutoSchema constructor:

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

class MyView(APIView):
schema = AutoSchema(component_name="Ulysses")
```

#### Override the default implementation

If you want to have more control and customization about how the schema's components are generated, you can override the `get_component_name` and `get_components` method from the AutoSchema class.

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

class CustomSchema(AutoSchema):
def get_components(self, path, method):
# Implement your custom implementation

def get_component_name(self, serializer):
# Implement your custom implementation

class CustomView(APIView):
"""APIView subclass with custom schema introspection."""
schema = CustomSchema()
Expand All @@ -326,3 +385,7 @@ class CustomView(APIView):
[openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
[openapi-tags]: https://swagger.io/specification/#tagObject
[openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17
[openapi-components]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#componentsObject
[openapi-reference]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#referenceObject
[openapi-generator]: https://github.com/OpenAPITools/openapi-generator
[swagger-codegen]: https://github.com/swagger-api/swagger-codegen
94 changes: 68 additions & 26 deletions rest_framework/schemas/openapi.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import warnings
from collections import OrderedDict
from decimal import Decimal
Expand Down Expand Up @@ -65,16 +66,26 @@ def get_schema(self, request=None, public=False):
Generate a OpenAPI schema.
"""
self._initialise_endpoints()
components_schemas = {}

# Iterate endpoints generating per method path operations.
# TODO: …and reference components.
paths = {}
_, 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):
continue

operation = view.schema.get_operation(path, method)
components = view.schema.get_components(path, method)
for k in components.keys():
if k not in components_schemas:
continue
if components_schemas[k] == components[k]:
continue
warnings.warn('Schema component "{}" has been overriden with a different value.'.format(k))

components_schemas.update(components)

# Normalise path for any provided mount url.
if path.startswith('/'):
path = path[1:]
Expand All @@ -92,21 +103,28 @@ def get_schema(self, request=None, public=False):
'paths': paths,
}

if len(components_schemas) > 0:
schema['components'] = {
'schemas': components_schemas
}

return schema

# View Inspectors


class AutoSchema(ViewInspector):

def __init__(self, operation_id_base=None, tags=None):
def __init__(self, tags=None, operation_id_base=None, component_name=None):
"""
:param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name.
:param component_name: user-defined component's name. If empty, it will be deducted from the Serializer's class name.
"""
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
self.operation_id_base = operation_id_base
self.component_name = component_name
super().__init__()

request_media_types = []
Expand Down Expand Up @@ -140,6 +158,43 @@ def get_operation(self, path, method):

return operation

def get_component_name(self, serializer):
"""
Compute the component's name from the serializer.
Raise an exception if the serializer's class name is "Serializer" (case-insensitive).
"""
if self.component_name is not None:
return self.component_name

# use the serializer's class name as the component name.
component_name = serializer.__class__.__name__
# We remove the "serializer" string from the class name.
pattern = re.compile("serializer", re.IGNORECASE)
component_name = pattern.sub("", component_name)

if component_name == "":
raise Exception(
'"{}" is an invalid class name for schema generation. '
'Serializer\'s class name should be unique and explicit. e.g. "ItemSerializer"'
.format(serializer.__class__.__name__)
)

return component_name

def get_components(self, path, method):
"""
Return components with their properties from the serializer.
"""
serializer = self._get_serializer(path, method)

if not isinstance(serializer, serializers.Serializer):
return {}

component_name = self.get_component_name(serializer)

content = self._map_serializer(serializer)
return {component_name: content}

def get_operation_id_base(self, path, method, action):
"""
Compute the base part for operation ID from the model, serializer or view name.
Expand Down Expand Up @@ -434,10 +489,6 @@ def _map_min_max(self, field, content):

def _map_serializer(self, serializer):
# Assuming we have a valid serializer instance.
# TODO:
# - field is Nested or List serializer.
# - Handle read_only/write_only for request/response differences.
# - could do this with readOnly/writeOnly and then filter dict.
required = []
properties = {}

Expand Down Expand Up @@ -542,6 +593,9 @@ def _get_serializer(self, path, method):
.format(view.__class__.__name__, method, path))
return None

def _get_reference(self, serializer):
return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))}

def _get_request_body(self, path, method):
if method not in ('PUT', 'PATCH', 'POST'):
return {}
Expand All @@ -551,20 +605,13 @@ def _get_request_body(self, path, method):
serializer = self._get_serializer(path, method)

if not isinstance(serializer, serializers.Serializer):
return {}

content = self._map_serializer(serializer)
# No required fields for PATCH
if method == 'PATCH':
content.pop('required', None)
# No read_only fields for request.
for name, schema in content['properties'].copy().items():
if 'readOnly' in schema:
del content['properties'][name]
item_schema = {}
else:
item_schema = self._get_reference(serializer)

return {
'content': {
ct: {'schema': content}
ct: {'schema': item_schema}
for ct in self.request_media_types
}
}
Expand All @@ -580,17 +627,12 @@ def _get_responses(self, path, method):

self.response_media_types = self.map_renderers(path, method)

item_schema = {}
serializer = self._get_serializer(path, method)

if isinstance(serializer, serializers.Serializer):
item_schema = self._map_serializer(serializer)
# No write_only fields for response.
for name, schema in item_schema['properties'].copy().items():
if 'writeOnly' in schema:
del item_schema['properties'][name]
if 'required' in item_schema:
item_schema['required'] = [f for f in item_schema['required'] if f != name]
if not isinstance(serializer, serializers.Serializer):
item_schema = {}
else:
item_schema = self._get_reference(serializer)

if is_list_view(path, method, self.view):
response_schema = {
Expand Down
Loading