Skip to content

Commit 8908934

Browse files
authored
Add OpenAPIRenderer and generate_schema management command. (#6229)
* Add OpenAPIRenderer and generate_schema command * Add both OpenAPIRenderer and JSONOpenAPIRenderer * Add flags to generate_schema command * Fix syntax error * Pull coreschema references into method, so they are only used if 'OpenAPIRenderer' is in use. * generate_schema -> generateschema, and fix to OpenAPIRenderer * Ensure that renderers generate bytes and generateschema outputs text * Drop unused import
1 parent c9d2bbc commit 8908934

File tree

5 files changed

+172
-3
lines changed

5 files changed

+172
-3
lines changed

rest_framework/compat.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,19 @@
1111
from django.views.generic import View
1212

1313
try:
14-
# Python 3 (required for 3.8+)
14+
# Python 3
1515
from collections.abc import Mapping # noqa
1616
except ImportError:
1717
# Python 2.7
1818
from collections import Mapping # noqa
1919

20+
try:
21+
# Python 3
22+
import urllib.parse as urlparse # noqa
23+
except ImportError:
24+
# Python 2.7
25+
from urlparse import urlparse # noqa
26+
2027
try:
2128
from django.urls import ( # noqa
2229
URLPattern,
@@ -136,6 +143,13 @@ def distinct(queryset, base):
136143
coreschema = None
137144

138145

146+
# pyyaml is optional
147+
try:
148+
import yaml
149+
except ImportError:
150+
yaml = None
151+
152+
139153
# django-crispy-forms is optional
140154
try:
141155
import crispy_forms

rest_framework/management/__init__.py

Whitespace-only changes.

rest_framework/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django.core.management.base import BaseCommand
2+
3+
from rest_framework.compat import coreapi
4+
from rest_framework.renderers import (
5+
CoreJSONRenderer, JSONOpenAPIRenderer, OpenAPIRenderer
6+
)
7+
from rest_framework.schemas.generators import SchemaGenerator
8+
9+
10+
class Command(BaseCommand):
11+
help = "Generates configured API schema for project."
12+
13+
def add_arguments(self, parser):
14+
parser.add_argument('--title', dest="title", default=None, type=str)
15+
parser.add_argument('--url', dest="url", default=None, type=str)
16+
parser.add_argument('--description', dest="description", default=None, type=str)
17+
parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json', 'corejson'], default='openapi', type=str)
18+
19+
def handle(self, *args, **options):
20+
assert coreapi is not None, 'coreapi must be installed.'
21+
22+
generator = SchemaGenerator(
23+
url=options['url'],
24+
title=options['title'],
25+
description=options['description']
26+
)
27+
28+
schema = generator.get_schema(request=None, public=True)
29+
30+
renderer = self.get_renderer(options['format'])
31+
output = renderer.render(schema, renderer_context={})
32+
self.stdout.write(output.decode('utf-8'))
33+
34+
def get_renderer(self, format):
35+
return {
36+
'corejson': CoreJSONRenderer(),
37+
'openapi': OpenAPIRenderer(),
38+
'openapi-json': JSONOpenAPIRenderer()
39+
}[format]

rest_framework/renderers.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424

2525
from rest_framework import VERSION, exceptions, serializers, status
2626
from rest_framework.compat import (
27-
INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi,
28-
pygments_css
27+
INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, coreschema,
28+
pygments_css, urlparse, yaml
2929
)
3030
from rest_framework.exceptions import ParseError
3131
from rest_framework.request import is_form_media_type, override_method
@@ -932,3 +932,119 @@ def render(self, data, media_type=None, renderer_context=None):
932932
indent = bool(renderer_context.get('indent', 0))
933933
codec = coreapi.codecs.CoreJSONCodec()
934934
return codec.dump(data, indent=indent)
935+
936+
937+
class _BaseOpenAPIRenderer:
938+
def get_schema(self, instance):
939+
CLASS_TO_TYPENAME = {
940+
coreschema.Object: 'object',
941+
coreschema.Array: 'array',
942+
coreschema.Number: 'number',
943+
coreschema.Integer: 'integer',
944+
coreschema.String: 'string',
945+
coreschema.Boolean: 'boolean',
946+
}
947+
948+
schema = {}
949+
if instance.__class__ in CLASS_TO_TYPENAME:
950+
schema['type'] = CLASS_TO_TYPENAME[instance.__class__]
951+
schema['title'] = instance.title,
952+
schema['description'] = instance.description
953+
if hasattr(instance, 'enum'):
954+
schema['enum'] = instance.enum
955+
return schema
956+
957+
def get_parameters(self, link):
958+
parameters = []
959+
for field in link.fields:
960+
if field.location not in ['path', 'query']:
961+
continue
962+
parameter = {
963+
'name': field.name,
964+
'in': field.location,
965+
}
966+
if field.required:
967+
parameter['required'] = True
968+
if field.description:
969+
parameter['description'] = field.description
970+
if field.schema:
971+
parameter['schema'] = self.get_schema(field.schema)
972+
parameters.append(parameter)
973+
return parameters
974+
975+
def get_operation(self, link, name, tag):
976+
operation_id = "%s_%s" % (tag, name) if tag else name
977+
parameters = self.get_parameters(link)
978+
979+
operation = {
980+
'operationId': operation_id,
981+
}
982+
if link.title:
983+
operation['summary'] = link.title
984+
if link.description:
985+
operation['description'] = link.description
986+
if parameters:
987+
operation['parameters'] = parameters
988+
if tag:
989+
operation['tags'] = [tag]
990+
return operation
991+
992+
def get_paths(self, document):
993+
paths = {}
994+
995+
tag = None
996+
for name, link in document.links.items():
997+
path = urlparse.urlparse(link.url).path
998+
method = link.action.lower()
999+
paths.setdefault(path, {})
1000+
paths[path][method] = self.get_operation(link, name, tag=tag)
1001+
1002+
for tag, section in document.data.items():
1003+
for name, link in section.links.items():
1004+
path = urlparse.urlparse(link.url).path
1005+
method = link.action.lower()
1006+
paths.setdefault(path, {})
1007+
paths[path][method] = self.get_operation(link, name, tag=tag)
1008+
1009+
return paths
1010+
1011+
def get_structure(self, data):
1012+
return {
1013+
'openapi': '3.0.0',
1014+
'info': {
1015+
'version': '',
1016+
'title': data.title,
1017+
'description': data.description
1018+
},
1019+
'servers': [{
1020+
'url': data.url
1021+
}],
1022+
'paths': self.get_paths(data)
1023+
}
1024+
1025+
1026+
class OpenAPIRenderer(_BaseOpenAPIRenderer):
1027+
media_type = 'application/vnd.oai.openapi'
1028+
charset = None
1029+
format = 'openapi'
1030+
1031+
def __init__(self):
1032+
assert coreapi, 'Using OpenAPIRenderer, but `coreapi` is not installed.'
1033+
assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.'
1034+
1035+
def render(self, data, media_type=None, renderer_context=None):
1036+
structure = self.get_structure(data)
1037+
return yaml.dump(structure, default_flow_style=False).encode('utf-8')
1038+
1039+
1040+
class JSONOpenAPIRenderer(_BaseOpenAPIRenderer):
1041+
media_type = 'application/vnd.oai.openapi+json'
1042+
charset = None
1043+
format = 'openapi-json'
1044+
1045+
def __init__(self):
1046+
assert coreapi, 'Using JSONOpenAPIRenderer, but `coreapi` is not installed.'
1047+
1048+
def render(self, data, media_type=None, renderer_context=None):
1049+
structure = self.get_structure(data)
1050+
return json.dumps(structure, indent=4).encode('utf-8')

0 commit comments

Comments
 (0)