From dac393e9e739152e0fbd6d1f53610a28b9a40a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Fri, 27 Sep 2019 13:08:57 +0200 Subject: [PATCH 1/6] Auto-document API endpoints using view function docstring The `swagger_auto_schema` view function decorator allows us to auto-document API endpoints. All swagger arguments are passed as kwargs to the decorator, but this feels a bit unnatural: we would like to use the function documentation as the endpoint summary and description. We introduce this feature here by reading the `__doc_`_attribute of the view function. --- lms/djangoapps/certificates/apis/v0/views.py | 69 ++++++++++---------- openedx/core/djangoapps/bookmarks/views.py | 30 ++++----- openedx/core/openapi.py | 24 +++++-- 3 files changed, 66 insertions(+), 57 deletions(-) diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index df7805cd50..f3f0b516e2 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -5,7 +5,6 @@ import logging import six from django.contrib.auth import get_user_model -from django.utils.decorators import method_decorator from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -134,9 +133,38 @@ class CertificatesDetailView(GenericAPIView): ) -@method_decorator(name='get', decorator=swagger_auto_schema( - operation_summary="Get a paginated list of bookmarks for a user.", - operation_description=u"""\ +class CertificatesListView(GenericAPIView): + """REST API endpoints for listing certificates.""" + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + + permission_classes = ( + C(IsAuthenticated) & ( + C(permissions.NotJwtRestrictedApplication) | + ( + C(permissions.JwtRestrictedApplication) & + permissions.JwtHasScope & + permissions.JwtHasUserFilterForRequestedUser + ) + ), + ) + + required_scopes = ['certificates:read'] + + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter( + 'username', + openapi.IN_PATH, + type=openapi.TYPE_STRING, + description="The users to get certificates for", + ) + ]) + def get(self, request, username): + """Get a paginated list of bookmarks for a user. + **Use Case** Get the list of viewable course certificates for a specific user. @@ -185,38 +213,7 @@ class CertificatesDetailView(GenericAPIView): "download_url": "http://www.example.com/cert.pdf", "grade": "0.98" }] - """, - manual_parameters=[ - openapi.Parameter( - 'username', - openapi.IN_PATH, - type=openapi.TYPE_STRING, - description="The users to get certificates for", - ), - ], -)) -class CertificatesListView(GenericAPIView): - """REST API endpoints for listing certificates.""" - authentication_classes = ( - JwtAuthentication, - OAuth2AuthenticationAllowInactiveUser, - SessionAuthenticationAllowInactiveUser, - ) - - permission_classes = ( - C(IsAuthenticated) & ( - C(permissions.NotJwtRestrictedApplication) | - ( - C(permissions.JwtRestrictedApplication) & - permissions.JwtHasScope & - permissions.JwtHasUserFilterForRequestedUser - ) - ), - ) - - required_scopes = ['certificates:read'] - - def get(self, request, username): + """ user_certs = [] if self._viewable_by_requestor(request, username): for user_cert in self._get_certificates_for_user(username): diff --git a/openedx/core/djangoapps/bookmarks/views.py b/openedx/core/djangoapps/bookmarks/views.py index 667bb0790a..ddea63a245 100644 --- a/openedx/core/djangoapps/bookmarks/views.py +++ b/openedx/core/djangoapps/bookmarks/views.py @@ -201,25 +201,21 @@ class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): return page - @swagger_auto_schema( - operation_summary="Create a new bookmark for a user.", - operation_description=u""" - The POST request only needs to contain one parameter "usage_id". - - Http400 is returned if the format of the request is not correct, - the usage_id is invalid or a block corresponding to the usage_id - could not be found. - - # Example Requests - - POST /api/bookmarks/v1/bookmarks/ - Request data: {"usage_id": } - - """, - ) + @swagger_auto_schema def post(self, request, *unused_args, **unused_kwargs): - """Create a new bookmark for a user.""" + """Create a new bookmark for a user. + The POST request only needs to contain one parameter "usage_id". + + Http400 is returned if the format of the request is not correct, + the usage_id is invalid or a block corresponding to the usage_id + could not be found. + + # Example Requests + + POST /api/bookmarks/v1/bookmarks/ + Request data: {"usage_id": } + """ if not request.data: return self.error_response(ugettext_noop(u'No data provided.'), DEFAULT_USER_MESSAGE) diff --git a/openedx/core/openapi.py b/openedx/core/openapi.py index 8b0591aab3..d4c056d6d2 100644 --- a/openedx/core/openapi.py +++ b/openedx/core/openapi.py @@ -44,7 +44,7 @@ def dedent(text): return textwrap.dedent(text) -def swagger_auto_schema(**kwargs): +def swagger_auto_schema(*args, **kwargs): """ Decorator for documenting an OpenAPI endpoint. @@ -55,11 +55,27 @@ def swagger_auto_schema(**kwargs): __ https://drf-yasg.readthedocs.io/en/stable/drf_yasg.html#drf_yasg.utils.swagger_auto_schema """ - if 'operation_description' in kwargs: - kwargs['operation_description'] = dedent(kwargs['operation_description']) + if args and callable(args[0]): + # decorator may be used with no argument + return swagger_auto_schema(*args[1:], **kwargs)(args[0]) + for param in kwargs.get('manual_parameters', ()): param.description = dedent(param.description) - return drf_swagger_auto_schema(**kwargs) + + 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): From 59635b689e2d8f2576571332c2c1c75a69536f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Fri, 27 Sep 2019 15:29:46 +0200 Subject: [PATCH 2/6] Simplify BookmarksListView.get docs Instead of relying on a `method_decorator`, we explicitely declare the `get` method. --- openedx/core/djangoapps/bookmarks/views.py | 61 ++++++++++++---------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/openedx/core/djangoapps/bookmarks/views.py b/openedx/core/djangoapps/bookmarks/views.py index ddea63a245..904f1f092b 100644 --- a/openedx/core/djangoapps/bookmarks/views.py +++ b/openedx/core/djangoapps/bookmarks/views.py @@ -11,7 +11,6 @@ import logging import eventtracking from django.conf import settings from django.core.exceptions import ObjectDoesNotExist -from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_noop from edx_rest_framework_extensions.paginators import DefaultPagination @@ -97,9 +96,36 @@ class BookmarksViewMixin(object): ) -@method_decorator(name='get', decorator=swagger_auto_schema( - operation_summary="Get a paginated list of bookmarks for a user.", - operation_description=u""" +class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): + """REST endpoints for lists of bookmarks.""" + + authentication_classes = (OAuth2Authentication, SessionAuthentication) + pagination_class = BookmarksPagination + permission_classes = (permissions.IsAuthenticated,) + serializer_class = BookmarkSerializer + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'course_id', + openapi.IN_QUERY, + type=openapi.TYPE_STRING, + description="The id of the course to limit the list", + ), + openapi.Parameter( + 'fields', + openapi.IN_QUERY, + type=openapi.TYPE_STRING, + description=""" + The fields to return: display_name, path. + """, + ), + ], + ) + def get(self, request, *args, **kwargs): + """ + Get a paginated list of bookmarks for a user. + The list can be filtered by passing parameter "course_id=" to only include bookmarks from a particular course. @@ -117,31 +143,8 @@ class BookmarksViewMixin(object): # Example Requests GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path - """, - manual_parameters=[ - openapi.Parameter( - 'course_id', - openapi.IN_QUERY, - type=openapi.TYPE_STRING, - description="The id of the course to limit the list", - ), - openapi.Parameter( - 'fields', - openapi.IN_QUERY, - type=openapi.TYPE_STRING, - description=""" - The fields to return: display_name, path. - """, - ), - ], -)) -class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): - """REST endpoints for lists of bookmarks.""" - - authentication_classes = (OAuth2Authentication, SessionAuthentication) - pagination_class = BookmarksPagination - permission_classes = (permissions.IsAuthenticated,) - serializer_class = BookmarkSerializer + """ + return super(BookmarksListView, self).get(request, *args, **kwargs) def get_serializer_context(self): """ From 33bd0f953a8aa674cb82d9a440fbd55f6098a66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Fri, 27 Sep 2019 15:31:59 +0200 Subject: [PATCH 3/6] Update swagger.yml --- docs/swagger.yaml | 188 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 28 deletions(-) diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 49939ec76b..10fff3622d 100755 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -112,20 +112,7 @@ paths: \ passing parameter \"page_size=\".\n\nTo include the optional\ \ fields pass the values in \"fields\" parameter\nas a comma separated list.\ \ Possible values are:\n\n* \"display_name\"\n* \"path\"\n\n# Example Requests\n\ - \nGET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path\n\ - \n# Response Values\n\n* count: The number of bookmarks in a course.\n\n*\ - \ next: The URI to the next page of bookmarks.\n\n* previous: The URI to the\ - \ previous page of bookmarks.\n\n* num_pages: The number of pages listing\ - \ bookmarks.\n\n* results: A list of bookmarks returned. Each collection\ - \ in the list\n contains these fields.\n\n * id: String. The identifier\ - \ string for the bookmark: {user_id},{usage_id}.\n\n * course_id: String.\ - \ The identifier string of the bookmark's course.\n\n * usage_id: String.\ - \ The identifier string of the bookmark's XBlock.\n\n * display_name: String.\ - \ (optional) Display name of the XBlock.\n\n * path: List. (optional) List\ - \ of dicts containing {\"usage_id\": , display_name:}\n\ - \ for the XBlocks from the top of the course tree till the parent of\ - \ the bookmarked XBlock.\n\n * created: ISO 8601 String. The timestamp\ - \ of bookmark's creation.\n\n" + \nGET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path" parameters: - name: page in: query @@ -139,7 +126,7 @@ paths: type: integer - name: course_id in: query - description: The id of the course, of course + description: The id of the course to limit the list type: string - name: fields in: query @@ -157,7 +144,7 @@ paths: .\n\nHttp400 is returned if the format of the request is not correct,\nthe\ \ usage_id is invalid or a block corresponding to the usage_id\ncould not\ \ be found.\n\n# Example Requests\n\nPOST /api/bookmarks/v1/bookmarks/\nRequest\ - \ data: {\"usage_id\": }\n\n" + \ data: {\"usage_id\": }" parameters: [] responses: '201': @@ -341,12 +328,10 @@ paths: get: operationId: certificates_v0_certificates_read summary: Get a paginated list of bookmarks for a user. - description: "**Use Case**\n\n * Get the list of viewable course certificates\ - \ for a specific user.\n\n**Example Request**\n\n GET /api/certificates/v0/certificates/{username}\n\ - \n**GET Parameters**\n\n A GET request must include the following parameters.\n\ - \n * username: A string representation of an user's username.\n\n**GET\ - \ Response Values**\n\n If the request for information about the user's\ - \ certificates is successful,\n an HTTP 200 \"OK\" response is returned.\n\ + description: "**Use Case**\n\nGet the list of viewable course certificates for\ + \ a specific user.\n\n**Example Request**\n\nGET /api/certificates/v0/certificates/{username}\n\ + \n**GET Response Values**\n\n If the request for information about the\ + \ user's certificates is successful,\n an HTTP 200 \"OK\" response is returned.\n\ \n The HTTP 200 response contains a list of dicts with the following keys/values.\n\ \n * username: A string representation of an user's username passed in\ \ the request.\n\n * course_id: A string representation of a Course ID.\n\ @@ -363,8 +348,13 @@ paths: : \"edX/DemoX/Demo_Course\",\n \"certificate_type\": \"verified\",\n\ \ \"created_date\": \"2015-12-03T13:14:28+0000\",\n \"status\"\ : \"downloadable\",\n \"is_passing\": true,\n \"download_url\"\ - : \"http://www.example.com/cert.pdf\",\n \"grade\": \"0.98\"\n }]\n" - parameters: [] + : \"http://www.example.com/cert.pdf\",\n \"grade\": \"0.98\"\n }]" + parameters: + - name: username + in: path + description: The users to get certificates for + type: string + required: true responses: '200': description: '' @@ -709,7 +699,14 @@ paths: /course_goals/v0/course_goals/: get: operationId: course_goals_v0_course_goals_list - description: description from swagger_auto_schema via method_decorator + summary: API calls to create and update a course goal. + description: "Validates incoming data to ensure that course_key maps to an actual\n\ + course and that the goal_key is a valid option.\n\n**Use Case**\n * Create\ + \ a new goal for a user.\n * Update an existing goal for a user\n\n**Example\ + \ Requests**\n POST /api/course_goals/v0/course_goals/\n Request\ + \ data: {\"course_key\": , \"goal_key\": \"\", \"user\"\ + : \"\"}\n\nReturns Http400 response if the course_key does not map\ + \ to a known\ncourse or if the goal_key does not map to a valid goal key." parameters: - name: page in: query @@ -2704,6 +2701,18 @@ paths: required: true type: string pattern: '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' + /experiments/v0/custom/REV-934/: + get: + operationId: experiments_v0_custom_REV-934_list + description: Return the if the course should be upsold in the mobile app, if + the user has appropriate permissions. + parameters: [] + responses: + '200': + description: '' + tags: + - experiments + parameters: [] /experiments/v0/data/: get: operationId: experiments_v0_data_list @@ -3577,7 +3586,7 @@ paths: /program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/enrollments/: get: operationId: program_enrollments_v1_programs_courses_enrollments_list - description: Defines the GET list endpoint for ProgramCourseEnrollment objects. + description: Get a list of students enrolled in a course within a program. parameters: - name: cursor in: query @@ -3690,7 +3699,7 @@ paths: - program_enrollments put: operationId: program_enrollments_v1_programs_enrollments_update - description: Create/modify program enrollments for a list of learners + description: Create/update program enrollments for a list of learners parameters: [] responses: '200': @@ -3699,7 +3708,7 @@ paths: - program_enrollments patch: operationId: program_enrollments_v1_programs_enrollments_partial_update - description: Modify program enrollments for a list of learners + description: Update program enrollments for a list of learners parameters: [] responses: '200': @@ -4592,6 +4601,129 @@ paths: required: true type: string pattern: ^[a-zA-Z0-9\-_]*$ + /xblock/v2/xblocks/{usage_key_str}/: + get: + operationId: xblock_v2_xblocks_read + description: Get metadata about the specified block. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key_str + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key_str}/handler/{user_id}-{secure_token}/{handler_name}/{suffix}: + get: + operationId: xblock_v2_xblocks_handler_read + description: Run an XBlock's handler and return the result + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + post: + operationId: xblock_v2_xblocks_handler_create + description: Run an XBlock's handler and return the result + parameters: [] + responses: + '201': + description: '' + tags: + - xblock + put: + operationId: xblock_v2_xblocks_handler_update + description: Run an XBlock's handler and return the result + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + patch: + operationId: xblock_v2_xblocks_handler_partial_update + description: Run an XBlock's handler and return the result + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + delete: + operationId: xblock_v2_xblocks_handler_delete + description: Run an XBlock's handler and return the result + parameters: [] + responses: + '204': + description: '' + tags: + - xblock + parameters: + - name: handler_name + in: path + required: true + type: string + - name: secure_token + in: path + required: true + type: string + - name: suffix + in: path + required: true + type: string + - name: usage_key_str + in: path + required: true + type: string + - name: user_id + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key_str}/handler_url/{handler_name}/: + get: + operationId: xblock_v2_xblocks_handler_url_read + summary: "Get an absolute URL which can be used (without any authentication)\ + \ to call\nthe given XBlock handler." + description: The URL will expire but is guaranteed to be valid for a minimum + of 2 days. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: handler_name + in: path + required: true + type: string + - name: usage_key_str + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key_str}/view/{view_name}/: + get: + operationId: xblock_v2_xblocks_view_read + description: Get the HTML, JS, and CSS needed to render the given XBlock. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key_str + in: path + required: true + type: string + - name: view_name + in: path + required: true + type: string definitions: BadgeClass: title: Badge class From e78d69695d1cc5f19bbbb8f2a9c44b836a147cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Fri, 27 Sep 2019 16:55:18 +0200 Subject: [PATCH 4/6] Ensure proper arguments in swagger decorator --- openedx/core/openapi.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openedx/core/openapi.py b/openedx/core/openapi.py index d4c056d6d2..a58aa6085a 100644 --- a/openedx/core/openapi.py +++ b/openedx/core/openapi.py @@ -55,9 +55,11 @@ def swagger_auto_schema(*args, **kwargs): __ https://drf-yasg.readthedocs.io/en/stable/drf_yasg.html#drf_yasg.utils.swagger_auto_schema """ - if args and callable(args[0]): - # decorator may be used with no argument - return swagger_auto_schema(*args[1:], **kwargs)(args[0]) + if args: + if callable(args[0]): + # decorator may be used with no argument + return swagger_auto_schema(*args[1:], **kwargs)(args[0]) + raise ValueError("Unsupported positional arguments") for param in kwargs.get('manual_parameters', ()): param.description = dedent(param.description) From 1d9a5ab4e776dde7b1d7ef9bc314708cc766957b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Fri, 27 Sep 2019 18:10:44 +0200 Subject: [PATCH 5/6] Rename `swagger_auto_schema` decorator --- lms/djangoapps/certificates/apis/v0/views.py | 4 ++-- openedx/core/djangoapps/bookmarks/views.py | 8 ++++---- openedx/core/openapi.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index f3f0b516e2..45d3ced28e 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 swagger_auto_schema, openapi +from openedx.core.openapi import document_api_view, openapi log = logging.getLogger(__name__) @@ -154,7 +154,7 @@ class CertificatesListView(GenericAPIView): required_scopes = ['certificates:read'] - @swagger_auto_schema(manual_parameters=[ + @document_api_view(manual_parameters=[ openapi.Parameter( 'username', openapi.IN_PATH, diff --git a/openedx/core/djangoapps/bookmarks/views.py b/openedx/core/djangoapps/bookmarks/views.py index 904f1f092b..a98c277e81 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 swagger_auto_schema, openapi +from openedx.core.openapi import document_api_view, openapi from xmodule.modulestore.exceptions import ItemNotFoundError from . import DEFAULT_FIELDS, OPTIONAL_FIELDS, api @@ -104,7 +104,7 @@ class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): permission_classes = (permissions.IsAuthenticated,) serializer_class = BookmarkSerializer - @swagger_auto_schema( + @document_api_view( manual_parameters=[ openapi.Parameter( 'course_id', @@ -204,7 +204,7 @@ class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): return page - @swagger_auto_schema + @document_api_view def post(self, request, *unused_args, **unused_kwargs): """Create a new bookmark for a user. @@ -313,7 +313,7 @@ class BookmarksDetailView(APIView, BookmarksViewMixin): log.error(error_message) return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND) - @swagger_auto_schema( + @document_api_view( operation_summary="Get a specific bookmark for a user.", operation_description=u""" # Example Requests diff --git a/openedx/core/openapi.py b/openedx/core/openapi.py index a58aa6085a..2a7087168e 100644 --- a/openedx/core/openapi.py +++ b/openedx/core/openapi.py @@ -44,7 +44,7 @@ def dedent(text): return textwrap.dedent(text) -def swagger_auto_schema(*args, **kwargs): +def document_api_view(*args, **kwargs): """ Decorator for documenting an OpenAPI endpoint. @@ -58,7 +58,7 @@ def swagger_auto_schema(*args, **kwargs): if args: if callable(args[0]): # decorator may be used with no argument - return swagger_auto_schema(*args[1:], **kwargs)(args[0]) + return document_api_view(*args[1:], **kwargs)(args[0]) raise ValueError("Unsupported positional arguments") for param in kwargs.get('manual_parameters', ()): From ae216c858483a51920bdf488c96c473371866313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Wed, 9 Oct 2019 11:31:58 +0200 Subject: [PATCH 6/6] 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. --- Makefile | 2 +- cms/urls.py | 2 +- lms/djangoapps/certificates/apis/v0/views.py | 9 +- lms/envs/common.py | 2 +- lms/urls.py | 2 +- openedx/core/apidocs.py | 162 ++++++++++++++++++ .../core/djangoapps/bookmarks/serializers.py | 2 +- openedx/core/djangoapps/bookmarks/views.py | 36 ++-- openedx/core/openapi.py | 105 ------------ 9 files changed, 185 insertions(+), 137 deletions(-) create mode 100644 openedx/core/apidocs.py delete mode 100644 openedx/core/openapi.py 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,), -)