Skip to content

Commit d6fbc32

Browse files
committed
Implement OpenAPI Components
1 parent 2a5c2f3 commit d6fbc32

File tree

4 files changed

+365
-85
lines changed

4 files changed

+365
-85
lines changed

docs/api-guide/schemas.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,72 @@ class MyView(APIView):
288288
...
289289
```
290290

291+
=======
292+
### Components
293+
294+
Since DRF 3.12, Schema uses the [OpenAPI Components](openapi-components). This method define components in the schema and [referenced them](openapi-reference) inside request and response objects. The component's name is deduced from the Serializer's name.
295+
296+
Using OpenAPI's components provides the following advantages:
297+
* The schema is more readable and lightweight.
298+
* If you use the schema to generate a SDK (using [openapi-generator](openapi-generator) or [swagger-codegen](swagger-codegen)). The generator can name your SDK's models.
299+
300+
### Handling component's schema errors
301+
302+
You may get the following error while generating the schema:
303+
```
304+
"Serializer" is an invalid class name for schema generation.
305+
Serializer's class name should be unique and explicit. e.g. "ItemSerializer".
306+
```
307+
308+
This error occurs when the Serializer name is "Serializer". You should choose a component's name unique across your schema and different than "Serializer".
309+
310+
You may also get the following warning:
311+
```
312+
Schema component "ComponentName" has been overriden with a different value.
313+
```
314+
315+
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.
316+
317+
You have two ways to solve the previous issues:
318+
* You can rename your serializer with a unique name and another name than "Serializer".
319+
* You can set the `component_name` kwarg parameter of the AutoSchema constructor (see below).
320+
* You can override the `get_component_name` method of the AutoSchema class (see below).
321+
322+
#### Set a custom component's name for your view
323+
324+
To override the component's name in your view, you can use the `component_name` parameter of the AutoSchema constructor:
325+
326+
```python
327+
from rest_framework.schemas.openapi import AutoSchema
328+
329+
class MyView(APIView):
330+
schema = AutoSchema(component_name="Ulysses")
331+
```
332+
333+
#### Override the default implementation
334+
335+
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.
336+
337+
```python
338+
from rest_framework.schemas.openapi import AutoSchema
339+
340+
class CustomSchema(AutoSchema):
341+
def get_components(self, path, method):
342+
# Implement your custom implementation
343+
344+
def get_component_name(self, serializer):
345+
# Implement your custom implementation
346+
347+
class CustomView(APIView):
348+
"""APIView subclass with custom schema introspection."""
349+
schema = CustomSchema()
350+
```
291351

292352
[openapi]: https://github.com/OAI/OpenAPI-Specification
293353
[openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions
294354
[openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
295355
[openapi-tags]: https://swagger.io/specification/#tagObject
356+
[openapi-components]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#componentsObject
357+
[openapi-reference]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#referenceObject
358+
[openapi-generator]: https://github.com/OpenAPITools/openapi-generator
359+
[swagger-codegen]: https://github.com/swagger-api/swagger-codegen

rest_framework/schemas/openapi.py

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
import warnings
23
from collections import OrderedDict
34
from decimal import Decimal
@@ -39,16 +40,26 @@ def get_schema(self, request=None, public=False):
3940
Generate a OpenAPI schema.
4041
"""
4142
self._initialise_endpoints()
43+
components_schemas = {}
4244

4345
# Iterate endpoints generating per method path operations.
44-
# TODO: …and reference components.
4546
paths = {}
4647
_, view_endpoints = self._get_paths_and_endpoints(None if public else request)
4748
for path, method, view in view_endpoints:
4849
if not self.has_view_permissions(path, method, view):
4950
continue
5051

5152
operation = view.schema.get_operation(path, method)
53+
components = view.schema.get_components(path, method)
54+
for k in components.keys():
55+
if k not in components_schemas:
56+
continue
57+
if components_schemas[k] == components[k]:
58+
continue
59+
warnings.warn('Schema component "{}" has been overriden with a different value.'.format(k))
60+
61+
components_schemas.update(components)
62+
5263
# Normalise path for any provided mount url.
5364
if path.startswith('/'):
5465
path = path[1:]
@@ -64,14 +75,23 @@ def get_schema(self, request=None, public=False):
6475
'paths': paths,
6576
}
6677

78+
if len(components_schemas) > 0:
79+
schema['components'] = {
80+
'schemas': components_schemas
81+
}
82+
6783
return schema
6884

6985
# View Inspectors
7086

7187

7288
class AutoSchema(ViewInspector):
7389

74-
def __init__(self, tags=None):
90+
def __init__(self, tags=None, component_name=None):
91+
"""
92+
:param component_name: user-defined component's name. If empty, it will be deducted from the Serializer's class name.
93+
"""
94+
self.component_name = component_name
7595
if tags and not all(isinstance(tag, str) for tag in tags):
7696
raise ValueError('tags must be a list or tuple of string.')
7797
self._tags = tags
@@ -108,6 +128,43 @@ def get_operation(self, path, method):
108128

109129
return operation
110130

131+
def get_component_name(self, serializer):
132+
"""
133+
Compute the component's name from the serializer.
134+
Raise an exception if the serializer's class name is "Serializer" (case-insensitive).
135+
"""
136+
if self.component_name is not None:
137+
return self.component_name
138+
139+
# use the serializer's class name as the component name.
140+
component_name = serializer.__class__.__name__
141+
# We remove the "serializer" string from the class name.
142+
pattern = re.compile("serializer", re.IGNORECASE)
143+
component_name = pattern.sub("", component_name)
144+
145+
if component_name == "":
146+
raise Exception(
147+
'"{}" is an invalid class name for schema generation. '
148+
'Serializer\'s class name should be unique and explicit. e.g. "ItemSerializer"'
149+
.format(serializer.__class__.__name__)
150+
)
151+
152+
return component_name
153+
154+
def get_components(self, path, method):
155+
"""
156+
Return components with their properties from the serializer.
157+
"""
158+
serializer = self._get_serializer(path, method)
159+
160+
if not isinstance(serializer, serializers.Serializer):
161+
return {}
162+
163+
component_name = self.get_component_name(serializer)
164+
165+
content = self._map_serializer(serializer)
166+
return {component_name: content}
167+
111168
def _get_operation_id(self, path, method):
112169
"""
113170
Compute an operation ID from the model, serializer or view name.
@@ -390,10 +447,6 @@ def _map_min_max(self, field, content):
390447

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

@@ -498,6 +551,9 @@ def _get_serializer(self, path, method):
498551
.format(view.__class__.__name__, method, path))
499552
return None
500553

554+
def _get_reference(self, serializer):
555+
return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))}
556+
501557
def _get_request_body(self, path, method):
502558
if method not in ('PUT', 'PATCH', 'POST'):
503559
return {}
@@ -507,20 +563,13 @@ def _get_request_body(self, path, method):
507563
serializer = self._get_serializer(path, method)
508564

509565
if not isinstance(serializer, serializers.Serializer):
510-
return {}
511-
512-
content = self._map_serializer(serializer)
513-
# No required fields for PATCH
514-
if method == 'PATCH':
515-
content.pop('required', None)
516-
# No read_only fields for request.
517-
for name, schema in content['properties'].copy().items():
518-
if 'readOnly' in schema:
519-
del content['properties'][name]
566+
item_schema = {}
567+
else:
568+
item_schema = self._get_reference(serializer)
520569

521570
return {
522571
'content': {
523-
ct: {'schema': content}
572+
ct: {'schema': item_schema}
524573
for ct in self.request_media_types
525574
}
526575
}
@@ -536,17 +585,12 @@ def _get_responses(self, path, method):
536585

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

539-
item_schema = {}
540588
serializer = self._get_serializer(path, method)
541589

542-
if isinstance(serializer, serializers.Serializer):
543-
item_schema = self._map_serializer(serializer)
544-
# No write_only fields for response.
545-
for name, schema in item_schema['properties'].copy().items():
546-
if 'writeOnly' in schema:
547-
del item_schema['properties'][name]
548-
if 'required' in item_schema:
549-
item_schema['required'] = [f for f in item_schema['required'] if f != name]
590+
if not isinstance(serializer, serializers.Serializer):
591+
item_schema = {}
592+
else:
593+
item_schema = self._get_reference(serializer)
550594

551595
if is_list_view(path, method, self.view):
552596
response_schema = {

0 commit comments

Comments
 (0)