diff --git a/Makefile b/Makefile index 2300b278e3..3d9c47e1e8 100644 --- a/Makefile +++ b/Makefile @@ -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/* diff --git a/cms/urls.py b/cms/urls.py index 81cb89df43..bb0108d821 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -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() diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index 45d3ced28e..827b31f4d7 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -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", ) ]) diff --git a/lms/envs/common.py b/lms/envs/common.py index 18c87b37e3..25ec18dc4d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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. diff --git a/lms/urls.py b/lms/urls.py index bb8f2d6c3a..0104f9152b 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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 diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py new file mode 100644 index 0000000000..6f2350c3d0 --- /dev/null +++ b/openedx/core/apidocs.py @@ -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,), +) diff --git a/openedx/core/djangoapps/bookmarks/serializers.py b/openedx/core/djangoapps/bookmarks/serializers.py index f38cb51ea6..993e0324e2 100644 --- a/openedx/core/djangoapps/bookmarks/serializers.py +++ b/openedx/core/djangoapps/bookmarks/serializers.py @@ -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 diff --git a/openedx/core/djangoapps/bookmarks/views.py b/openedx/core/djangoapps/bookmarks/views.py index a98c277e81..59c271b189 100644 --- a/openedx/core/djangoapps/bookmarks/views.py +++ b/openedx/core/djangoapps/bookmarks/views.py @@ -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) diff --git a/openedx/core/openapi.py b/openedx/core/openapi.py deleted file mode 100644 index 2a7087168e..0000000000 --- a/openedx/core/openapi.py +++ /dev/null @@ -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,), -)