Skip to content

Commit

Permalink
feat: Add swagger documentation.
Browse files Browse the repository at this point in the history
    * A new view under `/eox-tagging/api-docs` can be used to interact
      with the REST API
    * Added descriptions of all the parameters and methods used by the
      API
    * Included a backport fix for carltongibson/django-filter#1323
  • Loading branch information
MoisesGSalas committed Feb 10, 2021
1 parent ec1fd96 commit f64209a
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 7 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ Change Log
Unreleased
----------
Added
-----
* Swagger support alongside REST API documentation

[1.2.0] - 2020-02-03
[1.2.0] - 2021-02-03
--------------------

Added
Expand Down
60 changes: 60 additions & 0 deletions eox_tagging/api/v1/filters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Filter module for tags."""
import warnings # NOTE: to be removed alongside the backport

from django_filters import compat # NOTE: to be removed alongside the backport
from django_filters import rest_framework as filters

from eox_tagging.constants import AccessLevel
Expand Down Expand Up @@ -83,3 +86,60 @@ def filter_access_type(self, queryset, name, value): # pylint: disable=unused-a
queryset = queryset.filter(access=access) if access else queryset.none()

return queryset


class FilterBackend(filters.DjangoFilterBackend):
"""
Backport this fix (https://github.com/carltongibson/django-filter/pull/1323)
for range type filters.
The current version of django-filter on edx-platform (v2.2.0) presents a bug
were range filters don't produce the correct OpenAPI schema. This schema is
used by our documentation tools (drf-yasg). This backport should be dropped
when a new version of django-filter with the fix is released (probably v2.5.0)
"""

def get_schema_fields(self, view):
# This is not compatible with widgets where the query param differs from the
# filter's attribute name. Notably, this includes `MultiWidget`, where query
# params will be of the format `<name>_0`, `<name>_1`, etc...
assert compat.coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
assert compat.coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
try:
queryset = view.get_queryset()
except Exception: # pylint: disable=broad-except
queryset = None
warnings.warn(
"{} is not compatible with schema generation".format(view.__class__)
)

filterset_class = self.get_filterset_class(view, queryset)
if not filterset_class:
return []

return [self.build_coreapi_field(schema_field_name, field)
for field_name, field in filterset_class.base_filters.items()
for schema_field_name in self.get_schema_field_names(field_name, field)
]

def build_coreapi_field(self, name, field): # pylint: disable=missing-function-docstring
return compat.coreapi.Field(
name=name,
required=field.extra['required'],
location='query',
schema=self.get_coreschema_field(field),
)

def get_schema_field_names(self, field_name, field):
"""
Get the corresponding schema field names required to generate the openAPI schema
by referencing the widget suffixes if available.
"""
try:
suffixes = field.field_class.widget.suffixes
except AttributeError:
return [field_name]
else:
return [field_name] if not suffixes else [
'{}_{}'.format(field_name, suffix)
for suffix in suffixes if suffix
]
171 changes: 165 additions & 6 deletions eox_tagging/api/v1/viewset.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""
Viewset for Tags.
"""
from django_filters import rest_framework as filters
from edx_api_doc_tools import query_parameter, schema_for
from eox_audit_model.decorators import audit_method
from rest_framework import viewsets
from rest_framework import status, viewsets
from rest_framework.authentication import SessionAuthentication

from eox_tagging.api.v1.filters import TagFilter
from eox_tagging.api.v1.filters import FilterBackend, TagFilter
from eox_tagging.api.v1.pagination import TagApiPagination
from eox_tagging.api.v1.permissions import EoxTaggingAPIPermission
from eox_tagging.api.v1.serializers import TagSerializer
Expand All @@ -15,14 +15,173 @@
from eox_tagging.models import Tag


@schema_for(
"create",
"""
Creates a tag for a given object
There are three different types of objects that can be labeled with a tag: courses,\
users and enrollments. The type of objects that can be labeled (and extra\
validations for different fields) are defined in the configuration of the\
site.
**Example Request**
POST /eox-tagging/api/v1/tags/
{
"tag_type": "subscription_level",
"tag_value": "premium",
"target_type": "courseoverview",
"target_id": "course-v1:edX+DemoX+Demo_Course",
"access": "PUBLIC",
"owner_type": "site"
}
**Parameters**
- `tag_type` (**required**, string, _body_):
General category for the tag (i.e subscription_level). This value is set in\
the site configuration.
- `tag_value` (**required**, string, _body_):
An item of the category (i.e premium). If there isn't a validation in the\
site configuration it can take any string.
- `target_type` (**required**, string, _body_):
One of courseoverview, user, courseenrollment
- `target_id` (**required**, string, _body_): Identifier of the target\
object. For users, username; for courseoverview, course_id and for\
courseenrollments a string with the following format: "`username:\
course_id`"
- `activation_date` (**optional**, string, _body_):
DateTime format `YYYY-MM-DD HH:MM:SS`.
- `expiration_date` (**optional**, string, _body_):
DateTime format `YYYY-MM-DD HH:MM:SS`.
- `owner_type` (**optional**, string, _body_):
Owner of the tag, either `site` or `user`
- `access` (**optional**, string, _body_):
Visibility of the tag, either `PUBLIC`, `PRIVATE` or `PROTECTED`
""",
)
@schema_for(
"destroy",
"""
Delete single tag by key. Deleted tags are inactivated (soft delete)
""",
responses={status.HTTP_404_NOT_FOUND: "Not found"},
)
@schema_for(
"retrieve",
"""
Fetch details for a single tag by key
""",
responses={status.HTTP_404_NOT_FOUND: "Not found"},
)
@schema_for(
"list",
"""
Fetch a list of tags.
The list can be narrowed using the available filters.
- Some filters are incompatible with each other,\
namely `course_id`, `username` and `target_type`. The reason being that `course_id` and `username`\
have an implicit `target_type` of `courseoverview` and `user`.
- DateTime filters must have the following format `YY-MM-DD HH:MM:SS`. Time is optional, date is not.\
Time must be UTC.
- Parameters not defined bellow will be ignored. If you apply a filter with a typo you'll get the \
whole list of tags.
""",
parameters=[
query_parameter(
"key",
str,
"The unique identifier. Same as `GET /eox-tagging/api/v1/tags/{key}`",
),
query_parameter(
"status", str, "Filter active or inactive tags. Default: active"
),
query_parameter(
"include_inactive", bool, "If true include the inactive tags on the list. Default false"
),
query_parameter(
"tag_type",
str,
"The type of the tag, set on the configuration of the site (i.e. Subscription level)",
),
query_parameter("tag_value", str, "The value of the tag (i.e. Premium)"),
query_parameter(
"course_id",
str,
"Shortcut to filter objects of target_type `courseoverview` with id `course_id`.",
),
query_parameter(
"username",
str,
"Shortcut to filter objects of target_type `user` with id `username`.",
),
query_parameter(
"target_type",
str,
"The type of the object that was tagged, one of: `course`, `courseenrollment`, `user`",
),
query_parameter(
"enrollment_username",
str,
"User identifier (username) to be used when target_type=courseenrollment. "
"Can be omitted and is ignored for a different target_type",
),
query_parameter(
"enrollment_course_id",
str,
"Course identifier to be used when target_type=courseenrollment."
"Can be omitted and is ignored for a different target_type",
),
query_parameter(
"created_at_before",
str,
"Filter tags created before date. Format `YY-MM-DD HH:MM:SS`",
),
query_parameter(
"created_at_after",
str,
"Filter tags created after date. Format `YY-MM-DD HH:MM:SS`",
),
query_parameter(
"activation_date_before",
str,
"Filter tags created before date. Format `YY-MM-DD HH:MM:SS`",
),
query_parameter(
"activation_date_after",
str,
"Filter tags created after date. Format `YY-MM-DD HH:MM:SS`",
),
query_parameter(
"expiration_date_before",
str,
"Filter tags created before date. Format `YY-MM-DD HH:MM:SS`",
),
query_parameter(
"expiration_date_after",
str,
"Filter tags created after date. Format `YY-MM-DD HH:MM:SS`",
),
query_parameter("access", str, "Filter by access, One of `PUBLIC`, `PRIVATE`, `PROTECTED`"),
],
responses={status.HTTP_404_NOT_FOUND: "Not found"},
)
class TagViewSet(viewsets.ModelViewSet):
"""Viewset for listing and creating Tags."""

serializer_class = TagSerializer
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (EoxTaggingAPIPermission,)
pagination_class = TagApiPagination
filter_backends = (filters.DjangoFilterBackend,)
filter_backends = (FilterBackend,)
filter_class = TagFilter
lookup_field = "key"
http_method_names = ["get", "post", "delete", "head"]
Expand All @@ -38,7 +197,7 @@ def get_queryset(self):
return queryset

def create(self, request, *args, **kwargs):
""""Hijack the create method and use a wrapper function to perform the
"""Hijack the create method and use a wrapper function to perform the
audit process. The original parameters of create are not very useful in
raw form, this way we pass more useful information to our wrapper
function to be audited
Expand All @@ -51,7 +210,7 @@ def audited_create(headers, body): # pylint: disable=unused-argument
return audited_create(headers=request.headers, body=request.data)

def destroy(self, request, *args, **kwargs):
""""Hijack the destroy method and use a wrapper function to perform the
"""Hijack the destroy method and use a wrapper function to perform the
audit process. The original parameters of destroy are not very useful in
raw form, this way we pass more useful information to our wrapper
function to be audited
Expand Down
63 changes: 63 additions & 0 deletions eox_tagging/api_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Swagger view generator
"""
from django.conf import settings
from django.conf.urls import include, url
from django.urls import reverse
from drf_yasg.generators import OpenAPISchemaGenerator
from drf_yasg.openapi import SwaggerDict
from drf_yasg.views import get_schema_view
from edx_api_doc_tools import get_docs_cache_timeout, internal_utils, make_api_info
from rest_framework import permissions


class APISchemaGenerator(OpenAPISchemaGenerator):
"""
Schema generator for eox-core.
Define specific security definition using oauth without overwritting project wide
settings.
"""

def get_security_definitions(self):
security_definitions = {
"OAuth2": {
"flow": "application",
"tokenUrl": "{}{}".format(settings.LMS_ROOT_URL, reverse('access_token')),
"type": "oauth2",
},
}
security_definitions = SwaggerDict.as_odict(security_definitions)
return security_definitions


api_urls = [
url(r"eox-tagging/api/", include("eox_tagging.api.urls"))
]

api_info = make_api_info(
title="eox tagging",
version="v1",
email=" [email protected]",
description=internal_utils.dedent("""\
eox tagging REST API
eox Tagging provides the ability to apply a simple label to certain objects \
(courses, enrollments and users). The label or tag includes a timestamp for \
when the tag is should be considered active, as well as fields to include \
the general category of the tag (tag_type) and a value belonging to that \
category (tag_value).
eox tagging is meant to be a lightweight plugin with emphasis on flexibility\
most of the logic regarding the deactivation of tags at a given time must be\
handled separately.
"""),
)

docs_ui_view = get_schema_view(
api_info,
generator_class=APISchemaGenerator,
public=True,
permission_classes=[permissions.AllowAny],
patterns=api_urls,
).with_ui("swagger", cache_timeout=get_docs_cache_timeout())
2 changes: 2 additions & 0 deletions eox_tagging/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from django.conf.urls import include, url

from eox_tagging import views
from eox_tagging.api_schema import docs_ui_view

urlpatterns = [
url(r'^eox-info$', views.info_view, name='eox-info'),
url(r'api/', include('eox_tagging.api.urls')),
url(r'^api-docs/$', docs_ui_view, name='apidocs-ui'),
]

0 comments on commit f64209a

Please sign in to comment.