Rename API decorators to "apidocs" to be provider-agnostic

The API documentation decorators do not have to leak which solution we
use to generate the docs. Here, and as discussed in PR #21820, we rename
the `openapi` module to `apidocs`, and we make sure that this module
includes all the right functions to document API Views without referring
to Open API.
This commit is contained in:
Régis Behmo
2019-10-09 11:31:58 +02:00
parent 1d9a5ab4e7
commit ae216c8584
9 changed files with 185 additions and 137 deletions

View File

@@ -24,7 +24,7 @@ SWAGGER = docs/swagger.yaml
docs: api-docs guides ## build all the developer documentation for this repository
swagger: ## generate the swagger.yaml file
DJANGO_SETTINGS_MODULE=docs.docs_settings python manage.py lms generate_swagger --generator-class=openedx.core.openapi.ApiSchemaGenerator -o $(SWAGGER)
DJANGO_SETTINGS_MODULE=docs.docs_settings python manage.py lms generate_swagger --generator-class=openedx.core.apidocs.ApiSchemaGenerator -o $(SWAGGER)
api-docs: swagger ## build the REST api docs
rm -f docs/api/gen/*

View File

@@ -17,7 +17,7 @@ import openedx.core.djangoapps.lang_pref.views
from cms.djangoapps.contentstore.views.organization import OrganizationListView
from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance
from openedx.core.djangoapps.password_policy.forms import PasswordPolicyAwareAdminAuthForm
from openedx.core.openapi import schema_view
from openedx.core.apidocs import schema_view
django_autodiscover()

View File

@@ -20,7 +20,7 @@ from openedx.core.djangoapps.certificates.api import certificates_viewable_for_c
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.user_api.accounts.api import visible_fields
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.openapi import document_api_view, openapi
from openedx.core import apidocs
log = logging.getLogger(__name__)
@@ -154,11 +154,10 @@ class CertificatesListView(GenericAPIView):
required_scopes = ['certificates:read']
@document_api_view(manual_parameters=[
openapi.Parameter(
@apidocs.schema(parameters=[
apidocs.string_parameter(
'username',
openapi.IN_PATH,
type=openapi.TYPE_STRING,
apidocs.ParameterLocation.PATH,
description="The users to get certificates for",
)
])

View File

@@ -2516,7 +2516,7 @@ REST_FRAMEWORK = {
}
SWAGGER_SETTINGS = {
'DEFAULT_INFO': 'openedx.core.openapi.openapi_info',
'DEFAULT_INFO': 'openedx.core.apidocs.default_info',
}
# How long to cache OpenAPI schemas and UI, in seconds.

View File

@@ -45,7 +45,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.verified_track_content import views as verified_track_content_views
from openedx.core.openapi import schema_view
from openedx.core.apidocs import schema_view
from openedx.features.enterprise_support.api import enterprise_enabled
from static_template_view import views as static_template_view_views
from staticbook import views as staticbook_views

162
openedx/core/apidocs.py Normal file
View File

@@ -0,0 +1,162 @@
"""
Open API support.
"""
import textwrap
from drf_yasg import openapi
from drf_yasg.generators import OpenAPISchemaGenerator
from drf_yasg.utils import swagger_auto_schema as drf_swagger_auto_schema
from drf_yasg.views import get_schema_view
from rest_framework import permissions
# -- Code that will eventually be in another openapi-helpers repo -------------
class ApiSchemaGenerator(OpenAPISchemaGenerator):
"""A schema generator for /api/*
Only includes endpoints in the /api/* url tree, and sets the path prefix
appropriately.
"""
def get_endpoints(self, request):
endpoints = super(ApiSchemaGenerator, self).get_endpoints(request)
subpoints = {p: v for p, v in endpoints.items() if p.startswith("/api/")}
return subpoints
def determine_path_prefix(self, paths):
return "/api/"
def dedent(text):
"""
Dedent multi-line text nicely.
An initial empty line is ignored so that triple-quoted strings don't need
to start with a backslash.
"""
if "\n" in text:
first, rest = text.split("\n", 1)
if not first.strip():
# First line is blank, discard it.
text = rest
return textwrap.dedent(text)
def schema(parameters=None):
"""
Decorator for documenting an API endpoint.
The operation summary and description are taken from the function docstring. All
description fields should be in Markdown and will be automatically dedented.
Args:
parameters (Parameter list): each object may be conveniently defined with the
`parameter` function.
This is heavily inspired from the the `drf_yasg.utils.swagger_auto_schema`__
decorator, but callers do not need to know about this abstraction.
__ https://drf-yasg.readthedocs.io/en/stable/drf_yasg.html#drf_yasg.utils.swagger_auto_schema
"""
for param in parameters or ():
param.description = dedent(param.description)
def decorator(view_func):
"""
Final view decorator.
"""
operation_summary = None
operation_description = None
if view_func.__doc__ is not None:
doc_lines = view_func.__doc__.strip().split("\n")
if doc_lines:
operation_summary = doc_lines[0].strip()
if len(doc_lines) > 1:
operation_description = dedent("\n".join(doc_lines[1:]))
return drf_swagger_auto_schema(
manual_parameters=parameters,
operation_summary=operation_summary,
operation_description=operation_description
)(view_func)
return decorator
def is_schema_request(request):
"""Is this request serving an OpenAPI schema?"""
return request.query_params.get('format') == 'openapi'
class ParameterLocation(object):
"""Location of API parameter in request."""
BODY = openapi.IN_BODY
PATH = openapi.IN_PATH
QUERY = openapi.IN_QUERY
FORM = openapi.IN_FORM
HEADER = openapi.IN_HEADER
def string_parameter(name, in_, description=None):
"""
Convenient function for defining a string parameter.
Args:
name (str)
in_ (ParameterLocation attribute)
description (str)
"""
return parameter(name, in_, str, description=description)
def parameter(name, in_, param_type, description=None):
"""
Define typed parameters.
Args:
name (str)
in_ (ParameterLocation attribute)
type (type): one of object, str, float, int, bool, list, file.
description (str)
"""
openapi_type = None
if param_type is object:
openapi_type = openapi.TYPE_OBJECT
elif param_type is str:
openapi_type = openapi.TYPE_STRING
elif param_type is float:
openapi_type = openapi.TYPE_NUMBER
elif param_type is int:
openapi_type = openapi.TYPE_INTEGER
elif param_type is bool:
openapi_type = openapi.TYPE_BOOLEAN
elif param_type is list:
openapi_type = openapi.TYPE_ARRAY
elif param_type is file:
openapi_type = openapi.TYPE_FILE
else:
raise ValueError(u"Unsupported parameter type: '{}'".format(type))
return openapi.Parameter(
name,
in_,
type=openapi_type,
description=description
)
# -----------------------------------------------------
default_info = openapi.Info(
title="Open edX API",
default_version="v1",
description="APIs for access to Open edX information",
#terms_of_service="https://www.google.com/policies/terms/", # TODO: Do we have these?
contact=openapi.Contact(email="oscm@edx.org"),
#license=openapi.License(name="BSD License"), # TODO: What does this mean?
)
schema_view = get_schema_view(
default_info,
generator_class=ApiSchemaGenerator,
public=True,
permission_classes=(permissions.AllowAny,),
)

View File

@@ -7,7 +7,7 @@ import six
from rest_framework import serializers
from openedx.core.lib.api.serializers import CourseKeyField, UsageKeyField
from openedx.core.openapi import is_schema_request
from openedx.core.apidocs import is_schema_request
from . import DEFAULT_FIELDS, OPTIONAL_FIELDS

View File

@@ -26,7 +26,7 @@ from rest_framework_oauth.authentication import OAuth2Authentication
from openedx.core.djangoapps.bookmarks.api import BookmarksLimitReachedError
from openedx.core.lib.api.permissions import IsUserInUrl
from openedx.core.lib.url_utils import unquote_slashes
from openedx.core.openapi import document_api_view, openapi
from openedx.core import apidocs
from xmodule.modulestore.exceptions import ItemNotFoundError
from . import DEFAULT_FIELDS, OPTIONAL_FIELDS, api
@@ -104,21 +104,17 @@ class BookmarksListView(ListCreateAPIView, BookmarksViewMixin):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = BookmarkSerializer
@document_api_view(
manual_parameters=[
openapi.Parameter(
@apidocs.schema(
parameters=[
apidocs.string_parameter(
'course_id',
openapi.IN_QUERY,
type=openapi.TYPE_STRING,
apidocs.ParameterLocation.QUERY,
description="The id of the course to limit the list",
),
openapi.Parameter(
apidocs.string_parameter(
'fields',
openapi.IN_QUERY,
type=openapi.TYPE_STRING,
description="""
The fields to return: display_name, path.
""",
apidocs.ParameterLocation.QUERY,
description="The fields to return: display_name, path.",
),
],
)
@@ -204,7 +200,7 @@ class BookmarksListView(ListCreateAPIView, BookmarksViewMixin):
return page
@document_api_view
@apidocs.schema()
def post(self, request, *unused_args, **unused_kwargs):
"""Create a new bookmark for a user.
@@ -313,17 +309,13 @@ class BookmarksDetailView(APIView, BookmarksViewMixin):
log.error(error_message)
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
@document_api_view(
operation_summary="Get a specific bookmark for a user.",
operation_description=u"""
# Example Requests
GET /api/bookmarks/v1/bookmarks/{username},{usage_id}/?fields=display_name,path
""",
)
@apidocs.schema()
def get(self, request, username=None, usage_id=None):
"""
Get a specific bookmark for a user.
# Example Requests
GET /api/bookmarks/v1/bookmarks/{username},{usage_id}?fields=display_name,path
"""
usage_key_or_response = self.get_usage_key_or_error_response(usage_id=usage_id)

View File

@@ -1,105 +0,0 @@
"""
Open API support.
"""
import textwrap
from drf_yasg import openapi
from drf_yasg.generators import OpenAPISchemaGenerator
from drf_yasg.utils import swagger_auto_schema as drf_swagger_auto_schema
from drf_yasg.views import get_schema_view
from rest_framework import permissions
# -- Code that will eventually be in another openapi-helpers repo -------------
class ApiSchemaGenerator(OpenAPISchemaGenerator):
"""A schema generator for /api/*
Only includes endpoints in the /api/* url tree, and sets the path prefix
appropriately.
"""
def get_endpoints(self, request):
endpoints = super(ApiSchemaGenerator, self).get_endpoints(request)
subpoints = {p: v for p, v in endpoints.items() if p.startswith("/api/")}
return subpoints
def determine_path_prefix(self, paths):
return "/api/"
def dedent(text):
"""
Dedent multi-line text nicely.
An initial empty line is ignored so that triple-quoted strings don't need
to start with a backslash.
"""
if "\n" in text:
first, rest = text.split("\n", 1)
if not first.strip():
# First line is blank, discard it.
text = rest
return textwrap.dedent(text)
def document_api_view(*args, **kwargs):
"""
Decorator for documenting an OpenAPI endpoint.
Identical to `drf_yasg.utils.swagger_auto_schema`__ except that
description fields will be dedented properly. All description fields
should be in Markdown.
__ https://drf-yasg.readthedocs.io/en/stable/drf_yasg.html#drf_yasg.utils.swagger_auto_schema
"""
if args:
if callable(args[0]):
# decorator may be used with no argument
return document_api_view(*args[1:], **kwargs)(args[0])
raise ValueError("Unsupported positional arguments")
for param in kwargs.get('manual_parameters', ()):
param.description = dedent(param.description)
def decorator(view_func):
"""
Final view decorator.
"""
if view_func.__doc__ is not None:
doc_lines = view_func.__doc__.strip().split("\n")
if 'operation_summary' not in kwargs and doc_lines:
kwargs['operation_summary'] = doc_lines[0].strip()
if 'operation_description' not in kwargs and len(doc_lines) > 1:
kwargs['operation_description'] = "\n".join(doc_lines[1:])
if 'operation_description' in kwargs:
kwargs['operation_description'] = dedent(kwargs['operation_description'])
return drf_swagger_auto_schema(**kwargs)(view_func)
return decorator
def is_schema_request(request):
"""Is this request serving an OpenAPI schema?"""
return request.query_params.get('format') == 'openapi'
# -----------------------------------------------------
openapi_info = openapi.Info(
title="Open edX API",
default_version="v1",
description="APIs for access to Open edX information",
#terms_of_service="https://www.google.com/policies/terms/", # TODO: Do we have these?
contact=openapi.Contact(email="oscm@edx.org"),
#license=openapi.License(name="BSD License"), # TODO: What does this mean?
)
schema_view = get_schema_view(
openapi_info,
generator_class=ApiSchemaGenerator,
public=True,
permission_classes=(permissions.AllowAny,),
)