From ce1806a39e62c1949bfc97791a8e023026f5f675 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 13:19:40 +0100 Subject: [PATCH 1/8] Add OpenAPIRenderer and generate_schema command --- rest_framework/compat.py | 9 +- rest_framework/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/generate_schema.py | 46 +++++++++ rest_framework/renderers.py | 96 ++++++++++++++++++- 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 rest_framework/management/__init__.py create mode 100644 rest_framework/management/commands/__init__.py create mode 100644 rest_framework/management/commands/generate_schema.py diff --git a/rest_framework/compat.py b/rest_framework/compat.py index d28055a3ac..8b44815241 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -11,12 +11,19 @@ from django.views.generic import View try: - # Python 3 (required for 3.8+) + # Python 3 from collections.abc import Mapping # noqa except ImportError: # Python 2.7 from collections import Mapping # noqa +try: + # Python 3 + import urllib.parse as urlparse # noqa +except ImportError: + # Python 2.7 + from urlparse import urlparse # noqa + try: from django.urls import ( # noqa URLPattern, diff --git a/rest_framework/management/__init__.py b/rest_framework/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rest_framework/management/commands/__init__.py b/rest_framework/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rest_framework/management/commands/generate_schema.py b/rest_framework/management/commands/generate_schema.py new file mode 100644 index 0000000000..9ac17ec9ca --- /dev/null +++ b/rest_framework/management/commands/generate_schema.py @@ -0,0 +1,46 @@ +from django.core.management.base import BaseCommand + +from rest_framework.compat import coreapi +from rest_framework.renderers import CoreJSONRenderer, OpenAPIRenderer +from rest_framework.settings import api_settings + + +class Command(BaseCommand): + help = "Generates configured API schema for project." + + def add_arguments(self, parser): + # TODO + # SchemaGenerator allows passing: + # + # - title + # - url + # - description + # - urlconf + # - patterns + # + # Don't particularly want to pass these on the command-line. + # conf file? + # + # Other options to consider: + # - indent + # - ... + pass + + def handle(self, *args, **options): + assert coreapi is not None, 'coreapi must be installed.' + + generator_class = api_settings.DEFAULT_SCHEMA_GENERATOR_CLASS() + generator = generator_class() + + schema = generator.get_schema(request=None, public=True) + + renderer = self.get_renderer('openapi') + output = renderer.render(schema) + + self.stdout.write(output) + + def get_renderer(self, format): + return { + 'corejson': CoreJSONRenderer(), + 'openapi': OpenAPIRenderer() + } diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index a9645cc89b..6ced8e1a0e 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -24,8 +24,8 @@ from rest_framework import VERSION, exceptions, serializers, status from rest_framework.compat import ( - INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, - pygments_css + INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, coreschema, + pygments_css, urlparse ) from rest_framework.exceptions import ParseError from rest_framework.request import is_form_media_type, override_method @@ -932,3 +932,95 @@ def render(self, data, media_type=None, renderer_context=None): indent = bool(renderer_context.get('indent', 0)) codec = coreapi.codecs.CoreJSONCodec() return codec.dump(data, indent=indent) + + +class OpenAPIRenderer: + CLASS_TO_TYPENAME = { + coreschema.Object: 'object', + coreschema.Array: 'array', + coreschema.Number: 'number', + coreschema.Integer: 'integer', + coreschema.String: 'string', + coreschema.Boolean: 'boolean', + } + + def __init__(self): + assert coreapi, 'Using OpenAPIRenderer, but `coreapi` is not installed.' + + def get_schema(self, instance): + schema = {} + if instance.__class__ in self.CLASS_TO_TYPENAME: + schema['type'] = self.CLASS_TO_TYPENAME[instance.__class__] + schema['title'] = instance.title, + schema['description'] = instance.description + if hasattr(instance, 'enum'): + schema['enum'] = instance.enum + return schema + + def get_parameters(self, link): + parameters = [] + for field in link.fields: + if field.location not in ['path', 'query']: + continue + parameter = { + 'name': field.name, + 'in': field.location, + } + if field.required: + parameter['required'] = True + if field.description: + parameter['description'] = field.description + if field.schema: + parameter['schema'] = self.get_schema(field.schema) + parameters.append(parameter) + return parameters + + def get_operation(self, link, name, tag): + operation_id = "%s_%s" % (tag, name) if tag else name + parameters = self.get_parameters(link) + + operation = { + 'operationId': operation_id, + } + if link.title: + operation['summary'] = link.title + if link.description: + operation['description'] = link.description + if parameters: + operation['parameters'] = parameters + if tag: + operation['tags'] = [tag] + return operation + + def get_paths(self, document): + paths = {} + + tag = None + for name, link in document.links.items(): + path = urlparse.urlparse(link.url).path + method = link.action.lower() + paths.setdefault(path, {}) + paths[path][method] = self.get_operation(link, name, tag=tag) + + for tag, section in document.data.items(): + for name, link in section.links.items(): + path = urlparse.urlparse(link.url).path + method = link.action.lower() + paths.setdefault(path, {}) + paths[path][method] = self.get_operation(link, name, tag=tag) + + return paths + + def render(self, data, media_type=None, renderer_context=None): + return json.dumps({ + 'openapi': '3.0.0', + 'info': { + 'version': '', + 'title': data.title, + 'description': data.description + }, + 'servers': [{ + 'url': data.url + }], + 'paths': self.get_paths(data) + }, indent=4) From 5ccdded2a8beeff7aa35c973298f107bb05e61c1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 13:43:04 +0100 Subject: [PATCH 2/8] Add both OpenAPIRenderer and JSONOpenAPIRenderer --- rest_framework/compat.py | 7 ++++ .../management/commands/generate_schema.py | 7 ++-- rest_framework/renderers.py | 40 +++++++++++++++---- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 8b44815241..1d34828bdc 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -143,6 +143,13 @@ def distinct(queryset, base): coreschema = None +# pyyaml is optional +try: + import yaml +except ImportError: + yaml = None + + # django-crispy-forms is optional try: import crispy_forms diff --git a/rest_framework/management/commands/generate_schema.py b/rest_framework/management/commands/generate_schema.py index 9ac17ec9ca..1e3d83fb84 100644 --- a/rest_framework/management/commands/generate_schema.py +++ b/rest_framework/management/commands/generate_schema.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from rest_framework.compat import coreapi -from rest_framework.renderers import CoreJSONRenderer, OpenAPIRenderer +from rest_framework.renderers import CoreJSONRenderer, OpenAPIRenderer, JSONOpenAPIRenderer from rest_framework.settings import api_settings @@ -15,8 +15,6 @@ def add_arguments(self, parser): # - title # - url # - description - # - urlconf - # - patterns # # Don't particularly want to pass these on the command-line. # conf file? @@ -42,5 +40,6 @@ def handle(self, *args, **options): def get_renderer(self, format): return { 'corejson': CoreJSONRenderer(), - 'openapi': OpenAPIRenderer() + 'openapi': OpenAPIRenderer(), + 'openapi-json': JSONOpenAPIRenderer() } diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 6ced8e1a0e..93f69acfa5 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -25,7 +25,7 @@ from rest_framework import VERSION, exceptions, serializers, status from rest_framework.compat import ( INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, coreschema, - pygments_css, urlparse + pygments_css, urlparse, yaml ) from rest_framework.exceptions import ParseError from rest_framework.request import is_form_media_type, override_method @@ -934,7 +934,7 @@ def render(self, data, media_type=None, renderer_context=None): return codec.dump(data, indent=indent) -class OpenAPIRenderer: +class _BaseOpenAPIRenderer: CLASS_TO_TYPENAME = { coreschema.Object: 'object', coreschema.Array: 'array', @@ -944,9 +944,6 @@ class OpenAPIRenderer: coreschema.Boolean: 'boolean', } - def __init__(self): - assert coreapi, 'Using OpenAPIRenderer, but `coreapi` is not installed.' - def get_schema(self, instance): schema = {} if instance.__class__ in self.CLASS_TO_TYPENAME: @@ -1011,8 +1008,8 @@ def get_paths(self, document): return paths - def render(self, data, media_type=None, renderer_context=None): - return json.dumps({ + def get_structure(self, data): + return { 'openapi': '3.0.0', 'info': { 'version': '', @@ -1023,4 +1020,31 @@ def render(self, data, media_type=None, renderer_context=None): 'url': data.url }], 'paths': self.get_paths(data) - }, indent=4) + } + + +class OpenAPIRenderer(_BaseOpenAPIRenderer): + media_type = 'application/vnd.oai.openapi' + charset = None + format = 'openapi' + + def __init__(self): + assert coreapi, 'Using OpenAPIRenderer, but `coreapi` is not installed.' + assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.' + + def render(self, data, media_type=None, renderer_context=None): + structure = self.get_structure(data) + return yaml.dumps(structure, default_flow_style=False) + + +class JSONOpenAPIRenderer(_BaseOpenAPIRenderer): + media_type = 'application/vnd.oai.openapi+json' + charset = None + format = 'openapi-json' + + def __init__(self): + assert coreapi, 'Using JSONOpenAPIRenderer, but `coreapi` is not installed.' + + def render(self, data, media_type=None, renderer_context=None): + structure = self.get_structure(data) + return json.dumps(structure, indent=4) From 619a84f58302ed6d322e486e8283c0a162f03c81 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 13:56:18 +0100 Subject: [PATCH 3/8] Add flags to generate_schema command --- .../management/commands/generate_schema.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/rest_framework/management/commands/generate_schema.py b/rest_framework/management/commands/generate_schema.py index 1e3d83fb84..29bf655a82 100644 --- a/rest_framework/management/commands/generate_schema.py +++ b/rest_framework/management/commands/generate_schema.py @@ -9,30 +9,24 @@ class Command(BaseCommand): help = "Generates configured API schema for project." def add_arguments(self, parser): - # TODO - # SchemaGenerator allows passing: - # - # - title - # - url - # - description - # - # Don't particularly want to pass these on the command-line. - # conf file? - # - # Other options to consider: - # - indent - # - ... - pass + parser.add_argument('--title', dest="title", default=None, type=str) + parser.add_argument('--url', dest="url", default=None, type=str) + parser.add_argument('--description', dest="description", default=None, type=str) + parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json', 'corejson'], default='openapi', type=str) def handle(self, *args, **options): assert coreapi is not None, 'coreapi must be installed.' - generator_class = api_settings.DEFAULT_SCHEMA_GENERATOR_CLASS() + generator_class = api_settings.DEFAULT_SCHEMA_GENERATOR_CLASS( + url=options['url'] + title=options['title'] + description=options['description'] + ) generator = generator_class() schema = generator.get_schema(request=None, public=True) - renderer = self.get_renderer('openapi') + renderer = self.get_renderer(options['format']) output = renderer.render(schema) self.stdout.write(output) From 9873fb87a72983a6a6836aa5f998e5d39fbd7ae3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 13:58:33 +0100 Subject: [PATCH 4/8] Fix syntax error --- rest_framework/management/commands/generate_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/management/commands/generate_schema.py b/rest_framework/management/commands/generate_schema.py index 29bf655a82..eea9f3e084 100644 --- a/rest_framework/management/commands/generate_schema.py +++ b/rest_framework/management/commands/generate_schema.py @@ -18,8 +18,8 @@ def handle(self, *args, **options): assert coreapi is not None, 'coreapi must be installed.' generator_class = api_settings.DEFAULT_SCHEMA_GENERATOR_CLASS( - url=options['url'] - title=options['title'] + url=options['url'], + title=options['title'], description=options['description'] ) generator = generator_class() From 90fb5ae2fda7e29a91af2cc8fff5c131f6363e15 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 14:22:43 +0100 Subject: [PATCH 5/8] Pull coreschema references into method, so they are only used if 'OpenAPIRenderer' is in use. --- rest_framework/renderers.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 93f69acfa5..496ba70326 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -935,19 +935,19 @@ def render(self, data, media_type=None, renderer_context=None): class _BaseOpenAPIRenderer: - CLASS_TO_TYPENAME = { - coreschema.Object: 'object', - coreschema.Array: 'array', - coreschema.Number: 'number', - coreschema.Integer: 'integer', - coreschema.String: 'string', - coreschema.Boolean: 'boolean', - } - def get_schema(self, instance): + CLASS_TO_TYPENAME = { + coreschema.Object: 'object', + coreschema.Array: 'array', + coreschema.Number: 'number', + coreschema.Integer: 'integer', + coreschema.String: 'string', + coreschema.Boolean: 'boolean', + } + schema = {} - if instance.__class__ in self.CLASS_TO_TYPENAME: - schema['type'] = self.CLASS_TO_TYPENAME[instance.__class__] + if instance.__class__ in CLASS_TO_TYPENAME: + schema['type'] = CLASS_TO_TYPENAME[instance.__class__] schema['title'] = instance.title, schema['description'] = instance.description if hasattr(instance, 'enum'): From e5cc8463030c77750db313b876d4b878220d6075 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 14:39:42 +0100 Subject: [PATCH 6/8] generate_schema -> generateschema, and fix to OpenAPIRenderer --- .../commands/{generate_schema.py => generateschema.py} | 10 ++++++---- rest_framework/renderers.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) rename rest_framework/management/commands/{generate_schema.py => generateschema.py} (85%) diff --git a/rest_framework/management/commands/generate_schema.py b/rest_framework/management/commands/generateschema.py similarity index 85% rename from rest_framework/management/commands/generate_schema.py rename to rest_framework/management/commands/generateschema.py index eea9f3e084..04a4267c78 100644 --- a/rest_framework/management/commands/generate_schema.py +++ b/rest_framework/management/commands/generateschema.py @@ -1,7 +1,10 @@ from django.core.management.base import BaseCommand from rest_framework.compat import coreapi -from rest_framework.renderers import CoreJSONRenderer, OpenAPIRenderer, JSONOpenAPIRenderer +from rest_framework.renderers import ( + CoreJSONRenderer, JSONOpenAPIRenderer, OpenAPIRenderer +) +from rest_framework.schemas.generators import SchemaGenerator from rest_framework.settings import api_settings @@ -17,12 +20,11 @@ def add_arguments(self, parser): def handle(self, *args, **options): assert coreapi is not None, 'coreapi must be installed.' - generator_class = api_settings.DEFAULT_SCHEMA_GENERATOR_CLASS( + generator = SchemaGenerator( url=options['url'], title=options['title'], description=options['description'] ) - generator = generator_class() schema = generator.get_schema(request=None, public=True) @@ -36,4 +38,4 @@ def get_renderer(self, format): 'corejson': CoreJSONRenderer(), 'openapi': OpenAPIRenderer(), 'openapi-json': JSONOpenAPIRenderer() - } + }[format] diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 496ba70326..8fa3c55762 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -1034,7 +1034,7 @@ def __init__(self): def render(self, data, media_type=None, renderer_context=None): structure = self.get_structure(data) - return yaml.dumps(structure, default_flow_style=False) + return yaml.dump(structure, default_flow_style=False) class JSONOpenAPIRenderer(_BaseOpenAPIRenderer): From c6b733cc591ba6ce66cd54fe8c34f4fad768142e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 14:48:03 +0100 Subject: [PATCH 7/8] Ensure that renderers generate bytes and generateschema outputs text --- rest_framework/management/commands/generateschema.py | 5 ++--- rest_framework/renderers.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/rest_framework/management/commands/generateschema.py b/rest_framework/management/commands/generateschema.py index 04a4267c78..94b110aa17 100644 --- a/rest_framework/management/commands/generateschema.py +++ b/rest_framework/management/commands/generateschema.py @@ -29,9 +29,8 @@ def handle(self, *args, **options): schema = generator.get_schema(request=None, public=True) renderer = self.get_renderer(options['format']) - output = renderer.render(schema) - - self.stdout.write(output) + output = renderer.render(schema, renderer_context={}) + self.stdout.write(output.decode('utf-8')) def get_renderer(self, format): return { diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 8fa3c55762..375c38a857 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -1034,7 +1034,7 @@ def __init__(self): def render(self, data, media_type=None, renderer_context=None): structure = self.get_structure(data) - return yaml.dump(structure, default_flow_style=False) + return yaml.dump(structure, default_flow_style=False).encode('utf-8') class JSONOpenAPIRenderer(_BaseOpenAPIRenderer): @@ -1047,4 +1047,4 @@ def __init__(self): def render(self, data, media_type=None, renderer_context=None): structure = self.get_structure(data) - return json.dumps(structure, indent=4) + return json.dumps(structure, indent=4).encode('utf-8') From dcbeaf01284f71b94bb318ec813bfca9c89a08ee Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 3 Oct 2018 14:58:06 +0100 Subject: [PATCH 8/8] Drop unused import --- rest_framework/management/commands/generateschema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rest_framework/management/commands/generateschema.py b/rest_framework/management/commands/generateschema.py index 94b110aa17..214b667750 100644 --- a/rest_framework/management/commands/generateschema.py +++ b/rest_framework/management/commands/generateschema.py @@ -5,7 +5,6 @@ CoreJSONRenderer, JSONOpenAPIRenderer, OpenAPIRenderer ) from rest_framework.schemas.generators import SchemaGenerator -from rest_framework.settings import api_settings class Command(BaseCommand):