Skip to content

Commit 7c8edbc

Browse files
committed
Implement OpenAPI Components
1 parent 797518a commit 7c8edbc

File tree

4 files changed

+347
-86
lines changed

4 files changed

+347
-86
lines changed

docs/api-guide/schemas.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,65 @@ class CustomSchema(AutoSchema):
316316
def get_operation_id(self, path, method):
317317
pass
318318

319+
class MyView(APIView):
320+
schema = AutoSchema(component_name="Ulysses")
321+
```
322+
323+
### Components
324+
325+
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.
326+
327+
Using OpenAPI's components provides the following advantages:
328+
* The schema is more readable and lightweight.
329+
* 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.
330+
331+
### Handling component's schema errors
332+
333+
You may get the following error while generating the schema:
334+
```
335+
"Serializer" is an invalid class name for schema generation.
336+
Serializer's class name should be unique and explicit. e.g. "ItemSerializer".
337+
```
338+
339+
This error occurs when the Serializer name is "Serializer". You should choose a component's name unique across your schema and different than "Serializer".
340+
341+
You may also get the following warning:
342+
```
343+
Schema component "ComponentName" has been overriden with a different value.
344+
```
345+
346+
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.
347+
348+
You have two ways to solve the previous issues:
349+
* You can rename your serializer with a unique name and another name than "Serializer".
350+
* You can set the `component_name` kwarg parameter of the AutoSchema constructor (see below).
351+
* You can override the `get_component_name` method of the AutoSchema class (see below).
352+
353+
#### Set a custom component's name for your view
354+
355+
To override the component's name in your view, you can use the `component_name` parameter of the AutoSchema constructor:
356+
357+
```python
358+
from rest_framework.schemas.openapi import AutoSchema
359+
360+
class MyView(APIView):
361+
schema = AutoSchema(component_name="Ulysses")
362+
```
363+
364+
#### Override the default implementation
365+
366+
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.
367+
368+
```python
369+
from rest_framework.schemas.openapi import AutoSchema
370+
371+
class CustomSchema(AutoSchema):
372+
def get_components(self, path, method):
373+
# Implement your custom implementation
374+
375+
def get_component_name(self, serializer):
376+
# Implement your custom implementation
377+
319378
class CustomView(APIView):
320379
"""APIView subclass with custom schema introspection."""
321380
schema = CustomSchema()
@@ -326,3 +385,7 @@ class CustomView(APIView):
326385
[openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
327386
[openapi-tags]: https://swagger.io/specification/#tagObject
328387
[openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17
388+
[openapi-components]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#componentsObject
389+
[openapi-reference]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#referenceObject
390+
[openapi-generator]: https://github.com/OpenAPITools/openapi-generator
391+
[swagger-codegen]: https://github.com/swagger-api/swagger-codegen

rest_framework/schemas/openapi.py

Lines changed: 68 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
@@ -65,16 +66,26 @@ def get_schema(self, request=None, public=False):
6566
Generate a OpenAPI schema.
6667
"""
6768
self._initialise_endpoints()
69+
components_schemas = {}
6870

6971
# Iterate endpoints generating per method path operations.
70-
# TODO: …and reference components.
7172
paths = {}
7273
_, view_endpoints = self._get_paths_and_endpoints(None if public else request)
7374
for path, method, view in view_endpoints:
7475
if not self.has_view_permissions(path, method, view):
7576
continue
7677

7778
operation = view.schema.get_operation(path, method)
79+
components = view.schema.get_components(path, method)
80+
for k in components.keys():
81+
if k not in components_schemas:
82+
continue
83+
if components_schemas[k] == components[k]:
84+
continue
85+
warnings.warn('Schema component "{}" has been overriden with a different value.'.format(k))
86+
87+
components_schemas.update(components)
88+
7889
# Normalise path for any provided mount url.
7990
if path.startswith('/'):
8091
path = path[1:]
@@ -92,21 +103,28 @@ def get_schema(self, request=None, public=False):
92103
'paths': paths,
93104
}
94105

106+
if len(components_schemas) > 0:
107+
schema['components'] = {
108+
'schemas': components_schemas
109+
}
110+
95111
return schema
96112

97113
# View Inspectors
98114

99115

100116
class AutoSchema(ViewInspector):
101117

102-
def __init__(self, operation_id_base=None, tags=None):
118+
def __init__(self, tags=None, operation_id_base=None, component_name=None):
103119
"""
104120
:param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name.
121+
:param component_name: user-defined component's name. If empty, it will be deducted from the Serializer's class name.
105122
"""
106123
if tags and not all(isinstance(tag, str) for tag in tags):
107124
raise ValueError('tags must be a list or tuple of string.')
108125
self._tags = tags
109126
self.operation_id_base = operation_id_base
127+
self.component_name = component_name
110128
super().__init__()
111129

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

141159
return operation
142160

161+
def get_component_name(self, serializer):
162+
"""
163+
Compute the component's name from the serializer.
164+
Raise an exception if the serializer's class name is "Serializer" (case-insensitive).
165+
"""
166+
if self.component_name is not None:
167+
return self.component_name
168+
169+
# use the serializer's class name as the component name.
170+
component_name = serializer.__class__.__name__
171+
# We remove the "serializer" string from the class name.
172+
pattern = re.compile("serializer", re.IGNORECASE)
173+
component_name = pattern.sub("", component_name)
174+
175+
if component_name == "":
176+
raise Exception(
177+
'"{}" is an invalid class name for schema generation. '
178+
'Serializer\'s class name should be unique and explicit. e.g. "ItemSerializer"'
179+
.format(serializer.__class__.__name__)
180+
)
181+
182+
return component_name
183+
184+
def get_components(self, path, method):
185+
"""
186+
Return components with their properties from the serializer.
187+
"""
188+
serializer = self._get_serializer(path, method)
189+
190+
if not isinstance(serializer, serializers.Serializer):
191+
return {}
192+
193+
component_name = self.get_component_name(serializer)
194+
195+
content = self._map_serializer(serializer)
196+
return {component_name: content}
197+
143198
def get_operation_id_base(self, path, method, action):
144199
"""
145200
Compute the base part for operation ID from the model, serializer or view name.
@@ -434,10 +489,6 @@ def _map_min_max(self, field, content):
434489

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

@@ -542,6 +593,9 @@ def _get_serializer(self, path, method):
542593
.format(view.__class__.__name__, method, path))
543594
return None
544595

596+
def _get_reference(self, serializer):
597+
return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))}
598+
545599
def _get_request_body(self, path, method):
546600
if method not in ('PUT', 'PATCH', 'POST'):
547601
return {}
@@ -551,20 +605,13 @@ def _get_request_body(self, path, method):
551605
serializer = self._get_serializer(path, method)
552606

553607
if not isinstance(serializer, serializers.Serializer):
554-
return {}
555-
556-
content = self._map_serializer(serializer)
557-
# No required fields for PATCH
558-
if method == 'PATCH':
559-
content.pop('required', None)
560-
# No read_only fields for request.
561-
for name, schema in content['properties'].copy().items():
562-
if 'readOnly' in schema:
563-
del content['properties'][name]
608+
item_schema = {}
609+
else:
610+
item_schema = self._get_reference(serializer)
564611

565612
return {
566613
'content': {
567-
ct: {'schema': content}
614+
ct: {'schema': item_schema}
568615
for ct in self.request_media_types
569616
}
570617
}
@@ -580,17 +627,12 @@ def _get_responses(self, path, method):
580627

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

583-
item_schema = {}
584630
serializer = self._get_serializer(path, method)
585631

586-
if isinstance(serializer, serializers.Serializer):
587-
item_schema = self._map_serializer(serializer)
588-
# No write_only fields for response.
589-
for name, schema in item_schema['properties'].copy().items():
590-
if 'writeOnly' in schema:
591-
del item_schema['properties'][name]
592-
if 'required' in item_schema:
593-
item_schema['required'] = [f for f in item_schema['required'] if f != name]
632+
if not isinstance(serializer, serializers.Serializer):
633+
item_schema = {}
634+
else:
635+
item_schema = self._get_reference(serializer)
594636

595637
if is_list_view(path, method, self.view):
596638
response_schema = {

0 commit comments

Comments
 (0)