From d0ef9d46600d138d23e8d8c723cf25682a85b394 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Tue, 4 Aug 2020 15:52:26 +0200 Subject: [PATCH] [OAS3] OpenApi v3 Schema Generator compliant to Italian Agid guidelines Regading this: https://github.com/encode/django-rest-framework/issues/7452 . It come with some minor changes and specialized files/classes that would handle these specifications. --- .../management/commands/generateschema.py | 9 +- rest_framework/schemas/__init__.py | 4 +- rest_framework/schemas/agid_schema_views.py | 56 +++++++ rest_framework/schemas/generators.py | 7 +- rest_framework/schemas/openapi.py | 7 +- rest_framework/schemas/openapi_agid.py | 150 ++++++++++++++++++ 6 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 rest_framework/schemas/agid_schema_views.py create mode 100644 rest_framework/schemas/openapi_agid.py diff --git a/rest_framework/management/commands/generateschema.py b/rest_framework/management/commands/generateschema.py index 024306b65..9dcb55f34 100644 --- a/rest_framework/management/commands/generateschema.py +++ b/rest_framework/management/commands/generateschema.py @@ -6,6 +6,7 @@ from rest_framework.schemas import coreapi from rest_framework.schemas.openapi import SchemaGenerator OPENAPI_MODE = 'openapi' +OPENAPI_AGID_MODE = 'openapi-agid' # WiP COREAPI_MODE = 'coreapi' @@ -20,9 +21,9 @@ class Command(BaseCommand): parser.add_argument('--url', dest="url", default=None, type=str) parser.add_argument('--description', dest="description", default=None, type=str) if self.get_mode() == COREAPI_MODE: - parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json', 'corejson'], default='openapi', type=str) + parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json', 'openapi-agid', 'openapi-agid-json', 'corejson'], default='openapi', type=str) else: - parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json'], default='openapi', type=str) + parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json', 'openapi-agid', 'openapi-agid-json'], default='openapi', type=str) parser.add_argument('--urlconf', dest="urlconf", default=None, type=str) parser.add_argument('--generator_class', dest="generator_class", default=None, type=str) parser.add_argument('--file', dest="file", default=None, type=str) @@ -54,12 +55,16 @@ class Command(BaseCommand): 'corejson': renderers.CoreJSONRenderer, 'openapi': renderers.CoreAPIOpenAPIRenderer, 'openapi-json': renderers.CoreAPIJSONOpenAPIRenderer, + 'openapi-agid-json': renderers.CoreAPIJSONOpenAPIRenderer, + 'openapi-agid': renderers.CoreAPIOpenAPIRenderer, }[format] return renderer_cls() renderer_cls = { 'openapi': renderers.OpenAPIRenderer, 'openapi-json': renderers.JSONOpenAPIRenderer, + 'openapi-agid-json': renderers.JSONOpenAPIRenderer, + 'openapi-agid': renderers.OpenAPIRenderer, }[format] return renderer_cls() diff --git a/rest_framework/schemas/__init__.py b/rest_framework/schemas/__init__.py index b63cb2353..2e3c0fc72 100644 --- a/rest_framework/schemas/__init__.py +++ b/rest_framework/schemas/__init__.py @@ -32,7 +32,7 @@ def get_schema_view( public=False, patterns=None, generator_class=None, authentication_classes=api_settings.DEFAULT_AUTHENTICATION_CLASSES, permission_classes=api_settings.DEFAULT_PERMISSION_CLASSES, - version=None): + version=None, **kwargs): """ Return a schema view. """ @@ -44,7 +44,7 @@ def get_schema_view( generator = generator_class( title=title, url=url, description=description, - urlconf=urlconf, patterns=patterns, version=version + urlconf=urlconf, patterns=patterns, version=version, **kwargs ) # Avoid import cycle on APIView diff --git a/rest_framework/schemas/agid_schema_views.py b/rest_framework/schemas/agid_schema_views.py new file mode 100644 index 000000000..2d8dd4ed0 --- /dev/null +++ b/rest_framework/schemas/agid_schema_views.py @@ -0,0 +1,56 @@ +""" +rest_framework.schemas + +schemas: + __init__.py + generators.py # Top-down schema generation + inspectors.py # Per-endpoint view introspection + utils.py # Shared helper functions + views.py # Houses `SchemaView`, `APIView` subclass. + +We expose a minimal "public" API directly from `schemas`. This covers the +basic use-cases: + + from rest_framework.schemas import ( + AutoSchema, + ManualSchema, + get_schema_view, + SchemaGenerator, + ) + +Other access should target the submodules directly +""" +from rest_framework.settings import api_settings +api_settings.defaults['DEFAULT_SCHEMA_CLASS'] = \ + 'rest_framework.schemas.openapi_agid.AgidAutoSchema' + +from . import openapi_agid +# from .openapi_agid import AgidAutoSchema, AgidSchemaGenerator # noqa +from .inspectors import DefaultSchema # noqa + + +def get_schema_view( + title=None, url=None, description=None, urlconf=None, renderer_classes=None, + public=False, patterns=None, + authentication_classes=api_settings.DEFAULT_AUTHENTICATION_CLASSES, + permission_classes=api_settings.DEFAULT_PERMISSION_CLASSES, + version=None, **kwargs): + """ + Return a schema view. + """ + generator_class = openapi_agid.AgidSchemaGenerator + + generator = generator_class( + title=title, url=url, description=description, + urlconf=urlconf, patterns=patterns, version=version, **kwargs + ) + + # Avoid import cycle on APIView + from .views import SchemaView + return SchemaView.as_view( + renderer_classes=renderer_classes, + schema_generator=generator, + public=public, + authentication_classes=authentication_classes, + permission_classes=permission_classes, + ) diff --git a/rest_framework/schemas/generators.py b/rest_framework/schemas/generators.py index d3c6446aa..afd6b786b 100644 --- a/rest_framework/schemas/generators.py +++ b/rest_framework/schemas/generators.py @@ -151,7 +151,9 @@ class BaseSchemaGenerator: # Set by 'SCHEMA_COERCE_PATH_PK'. coerce_path_pk = None - def __init__(self, title=None, url=None, description=None, patterns=None, urlconf=None, version=None): + def __init__(self, title=None, url=None, description=None, + patterns=None, urlconf=None, version=None, **kwargs): + if url and not url.endswith('/'): url += '/' @@ -165,6 +167,9 @@ class BaseSchemaGenerator: self.url = url self.endpoints = None + for k,v in kwargs.items(): + setattr(self, k, v) + def _initialise_endpoints(self): if self.endpoints is None: inspector = self.endpoint_inspector_cls(self.patterns, self.urlconf) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 9774a94c7..c9b910b29 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -113,9 +113,8 @@ class SchemaGenerator(BaseSchemaGenerator): return schema + # View Inspectors - - class AutoSchema(ViewInspector): def __init__(self, tags=None, operation_id_base=None, component_name=None): @@ -471,13 +470,15 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.FloatField): content = { 'type': 'number', + 'format': 'float' } self._map_min_max(field, content) return content if isinstance(field, serializers.IntegerField): content = { - 'type': 'integer' + 'type': 'integer', + 'format': 'int64' } self._map_min_max(field, content) # 2147483647 is max for int32_size, so we use int64 for format diff --git a/rest_framework/schemas/openapi_agid.py b/rest_framework/schemas/openapi_agid.py new file mode 100644 index 000000000..3428a6ccc --- /dev/null +++ b/rest_framework/schemas/openapi_agid.py @@ -0,0 +1,150 @@ +from rest_framework import ( + RemovedInDRF314Warning, exceptions, renderers, serializers +) +from urllib.parse import urljoin + +from . openapi import SchemaGenerator, AutoSchema +from . utils import get_pk_description, is_list_view + + +class AgidSchemaGenerator(SchemaGenerator): + + def get_info(self): + # Title and version are required by openapi specification 3.x + info = { + 'title': self.title or '', + 'version': self.version or '', + 'contact': getattr(self, 'contact', {}), + 'termsOfService': getattr(self, 'termsOfService', {}), + 'license': getattr(self, 'license', {}), + 'x-api-id': getattr(self, 'x-api-id', {}), + 'x-summary': getattr(self, 'x-summary', {}), + } + + if self.description is not None: + info['description'] = self.description + + return info + + def get_servers(self): + servers = getattr(self, 'servers', {}) + return servers + + def get_tags(self): + tags = getattr(self, 'tags', {}), + return tags[0] if isinstance(tags, tuple) else tags + + def get_schema(self, request=None, public=False): + """ + Generate a OpenAPI schema. + """ + self._initialise_endpoints() + components_schemas = {} + + # Iterate endpoints generating per method path operations. + 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:] + + # AGID - remove trailing / from urls + if path.endswith('/'): + path = path[:-1] + + path = urljoin(self.url or '/', path) + + paths.setdefault(path, {}) + paths[path][method.lower()] = operation + + self.check_duplicate_operation_id(paths) + + # Compile final schema. + schema = { + 'openapi': '3.0.2', + 'info': self.get_info(), + 'tags': self.get_tags(), + 'servers': self.get_servers(), + 'paths': paths, + + } + + if len(components_schemas) > 0: + schema['components'] = { + 'schemas': components_schemas + } + + return schema + + + +class AgidAutoSchema(AutoSchema): + + def get_responses(self, path, method): + if method == 'DELETE': + return { + '204': { + 'description': '' + } + } + + self.response_media_types = self.map_renderers(path, method) + + serializer = self.get_serializer(path, method) + + 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 = { + 'type': 'object', + 'items': item_schema, + } + paginator = self.get_paginator() + if paginator: + response_schema = paginator.get_paginated_response_schema(response_schema) + else: + response_schema = item_schema + status_code = '201' if method == 'POST' else '200' + return { + status_code: { + 'content': { + ct: {'schema': response_schema} + for ct in self.response_media_types + }, + # description is a mandatory property, + # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#responseObject + # TODO: put something meaningful into it + 'description': "" + } + } + + def get_tags(self, path, method): + # If user have specified tags, use them. + if self._tags: + return self._tags + + # First element of a specific path could be valid tag. This is a fallback solution. + # PUT, PATCH, GET(Retrieve), DELETE: /user_profile/{id}/ tags = [user-profile] + # POST, GET(List): /user_profile/ tags = [user-profile] + if path.startswith('/'): + path = path[1:] + + return [path.split('/')[0].replace('_', '-')]