Refactor the REST API app

Remove the APIRoot view.

Remove the Endpoint class.

Remove the EndpointSerializer.

Move API documentation generation from the root urls module
to the app's urls module.

Update the app API URL generation to be based on viewsets
instead of an custom api_urls list.

Remove MayanObjectPermissionsFilter and replace it with
MayanViewSetObjectPermissionsFilter which allows mapping
a required permission to a specific viewset action.

Signed-off-by: Roberto Rosario <Roberto.Rosario@mayan-edms.com>
This commit is contained in:
Roberto Rosario
2019-02-06 05:12:14 -04:00
parent 7ba47d5c5f
commit 627056f1ae
13 changed files with 228 additions and 128 deletions

View File

@@ -1,31 +1,11 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import renderers from drf_yasg.views import get_schema_view
from rest_framework import permissions, renderers
from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.schemas.generators import EndpointEnumerator
from .classes import Endpoint from .schemas import openapi_info
from .serializers import EndpointSerializer
class APIRoot(APIView):
swagger_schema = None
def get(self, request, format=None):
"""
get: Return a list of all endpoints.
"""
endpoint_enumerator = EndpointEnumerator()
endpoints = []
for url in sorted(set([entry[0].split('/')[2] for entry in endpoint_enumerator.get_api_endpoints()])):
if url:
endpoints.append(Endpoint(label=url))
serializer = EndpointSerializer(endpoints, many=True)
return Response(serializer.data)
class BrowseableObtainAuthToken(ObtainAuthToken): class BrowseableObtainAuthToken(ObtainAuthToken):
@@ -33,3 +13,11 @@ class BrowseableObtainAuthToken(ObtainAuthToken):
Obtain an API authentication token. Obtain an API authentication token.
""" """
renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer) renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer)
schema_view = get_schema_view(
openapi_info,
validators=['flex', 'ssv'],
public=True,
permission_classes=(permissions.AllowAny,),
)

View File

@@ -5,6 +5,8 @@ from django.conf import settings
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import routers
from mayan.apps.common import MayanAppConfig, menu_tools from mayan.apps.common import MayanAppConfig, menu_tools
from .links import ( from .links import (
@@ -21,7 +23,7 @@ class RESTAPIApp(MayanAppConfig):
def ready(self): def ready(self):
super(RESTAPIApp, self).ready() super(RESTAPIApp, self).ready()
from .urls import api_urls from .urls import urlpatterns
settings.STRONGHOLD_PUBLIC_URLS += (r'^/%s/.+$' % self.app_url,) settings.STRONGHOLD_PUBLIC_URLS += (r'^/%s/.+$' % self.app_url,)
menu_tools.bind_links( menu_tools.bind_links(
@@ -29,8 +31,14 @@ class RESTAPIApp(MayanAppConfig):
link_api, link_api_documentation, link_api_documentation_redoc link_api, link_api_documentation, link_api_documentation_redoc
) )
) )
router = routers.DefaultRouter()
for app in apps.get_app_configs(): for app in apps.get_app_configs():
if getattr(app, 'has_rest_api', False): if getattr(app, 'has_rest_api', False):
app_api_urls = import_string('{}.urls.api_urls'.format(app.name)) try:
api_urls.extend(app_api_urls) for entry in import_string('{}.urls.api_router_entries'.format(app.name)):
router.register(**entry)
except ImportError:
pass
urlpatterns.extend(router.urls)

View File

@@ -1,10 +0,0 @@
from __future__ import unicode_literals
class Endpoint(object):
def __init__(self, label):
self.label = label
@property
def url(self):
return '/api/{}/'.format(self.label)

View File

@@ -5,18 +5,27 @@ from rest_framework.filters import BaseFilterBackend
from mayan.apps.acls.models import AccessControlList from mayan.apps.acls.models import AccessControlList
class MayanObjectPermissionsFilter(BaseFilterBackend): class MayanViewSetObjectPermissionsFilter(BaseFilterBackend):
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
# TODO: fix variable name to make it clear it should be a single """
# permission Filter the API view queryset by access using a permission.
Requires the object_permission_map class attribute which is a dictionary
that matches a view action ('update', 'list', etc) to a single
permission instance.
Example: object_permission_map = {
'update': permission_..._edit
'list': permission_..._view
}
"""
object_permission_dictionary = getattr(view, 'object_permission_map', {})
object_permission = object_permission_dictionary.get(
view.action, None
)
required_permissions = getattr( if object_permission:
view, 'mayan_object_permissions', {} return AccessControlList.objects.restrict_queryset(
).get(request.method, None) permission=object_permission, queryset=queryset,
user=request.user
if required_permissions:
return AccessControlList.objects.filter_by_access(
required_permissions[0], request.user, queryset=queryset
) )
else: else:
return queryset return queryset

View File

@@ -0,0 +1,48 @@
from __future__ import absolute_import, unicode_literals
from rest_framework import generics
from mayan.apps.rest_api.filters import MayanObjectPermissionsFilter
from mayan.apps.rest_api.permissions import MayanPermission
class ListAPIView(generics.ListAPIView):
"""
requires:
object_permission = {'GET': ...}
"""
filter_backends = (MayanObjectPermissionsFilter,)
class ListCreateAPIView(generics.ListCreateAPIView):
"""
requires:
object_permission = {'GET': ...}
view_permission = {'POST': ...}
"""
filter_backends = (MayanObjectPermissionsFilter,)
permission_classes = (MayanPermission,)
class RetrieveDestroyAPIView(generics.RetrieveDestroyAPIView):
"""
requires:
object_permission = {
'DELETE': ...,
'GET': ...,
}
"""
filter_backends = (MayanObjectPermissionsFilter,)
class RetrieveUpdateDestroyAPIView(generics.RetrieveUpdateDestroyAPIView):
"""
requires:
object_permission = {
'DELETE': ...,
'GET': ...,
'PATCH': ...,
'PUT': ...
}
"""
filter_backends = (MayanObjectPermissionsFilter,)

View File

@@ -11,14 +11,14 @@ from .icons import (
link_api = Link( link_api = Link(
icon_class=icon_api, tags='new_window', text=_('REST API'), icon_class=icon_api, tags='new_window', text=_('REST API'),
view='rest_api:api_root' view='rest_api:api-root'
) )
link_api_documentation = Link( link_api_documentation = Link(
icon_class=icon_api_documentation, tags='new_window', icon_class=icon_api_documentation, tags='new_window',
text=_('API Documentation (Swagger)'), view='schema-swagger-ui' text=_('API Documentation (Swagger)'), view='rest_api:schema-swagger-ui'
) )
link_api_documentation_redoc = Link( link_api_documentation_redoc = Link(
icon_class=icon_api_documentation_redoc, tags='new_window', icon_class=icon_api_documentation_redoc, tags='new_window',
text=_('API Documentation (ReDoc)'), view='schema-redoc' text=_('API Documentation (ReDoc)'), view='rest_api:schema-redoc'
) )

View File

@@ -0,0 +1,54 @@
from __future__ import absolute_import, unicode_literals
from django.core.exceptions import ImproperlyConfigured
from mayan.apps.acls.models import AccessControlList
class ExternalObjectListSerializerMixin(object):
class Meta:
external_object_list_model = None
external_object_list_permission = None
external_object_list_queryset = None
external_object_list_pk_field = None
external_object_list_pk_list_field = None
def get_external_object_list(self):
queryset = self.get_external_object_list_queryset()
if self.Meta.external_object_list_permission:
queryset = AccessControlList.objects.restrict_queryset(
permission=self.Meta.external_object_list_permission,
queryset=queryset,
user=self.context['request'].user
)
if self.Meta.external_object_list_pk_field:
id_list = (
self.validated_data.get(self.Meta.external_object_list_pk_field),
)
elif self.Meta.external_object_list_pk_list_field:
id_list = self.validated_data.get(
self.Meta.external_object_list_pk_list_field, ''
).split(',')
else:
raise ImproperlyConfigured(
'ExternalObjectListSerializerMixin requires a '
'external_object_list__pk_field a '
'external_object_list_pk_list_field.'
)
return queryset.filter(pk__in=id_list)
def get_external_object_list_queryset(self):
if self.Meta.external_object_list_model:
queryset = self.Meta.external_object_list_model._meta.default_manager.all()
elif self.Meta.external_object_list_queryset:
return self.Meta.external_object_list_queryset
else:
raise ImproperlyConfigured(
'ExternalObjectListSerializerMixin requires a '
'external_object_list_model or a external_object_list_queryset.'
)
return queryset

View File

@@ -1,26 +1,31 @@
from __future__ import absolute_import from __future__ import absolute_import, unicode_literals
from __future__ import unicode_literals
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from mayan.apps.acls.models import AccessControlList
from mayan.apps.permissions import Permission from mayan.apps.permissions import Permission
class MayanPermission(BasePermission): class MayanViewSetPermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
required_permission = getattr( """
view, 'mayan_view_permissions', {} Block the API view by access using a permission.
).get(request.method, None) Required the view_permission_map class attribute which is a dictionary
that matches a view actions ('create', 'destroy', etc) to a single
permission instance.
Example: view_permission_map = {
'update': permission_..._edit
'list': permission_..._view
}
"""
view_permission_dictionary = getattr(view, 'view_permission_map', {})
view_permission = view_permission_dictionary.get(view.action, None)
if required_permission: if view_permission:
try: try:
Permission.check_permissions( Permission.check_user_permission(
requester=request.user, permissions=required_permission permission=view_permission, user=request.user
) )
except PermissionDenied: except PermissionDenied:
return False return False
@@ -28,35 +33,3 @@ class MayanPermission(BasePermission):
return True return True
else: else:
return True return True
def has_object_permission(self, request, view, obj):
required_permission = getattr(
view, 'mayan_object_permissions', {}
).get(request.method, None)
object_permissions_raise_404 = getattr(
view, 'mayan_object_permissions_raise_404', ()
)
if required_permission:
try:
if hasattr(view, 'mayan_permission_attribute_check'):
AccessControlList.objects.check_access(
permissions=required_permission,
user=request.user, obj=obj,
related=view.mayan_permission_attribute_check
)
else:
AccessControlList.objects.check_access(
permissions=required_permission, user=request.user,
obj=obj
)
except PermissionDenied:
if request.method in object_permissions_raise_404:
raise Http404
else:
return False
else:
return True
else:
return True

View File

@@ -0,0 +1,35 @@
from __future__ import unicode_literals
from rest_framework.relations import HyperlinkedIdentityField
from mayan.apps.common.utils import resolve_attribute
class MultiKwargHyperlinkedIdentityField(HyperlinkedIdentityField):
def __init__(self, *args, **kwargs):
self.view_kwargs = kwargs.pop('view_kwargs', [])
super(MultiKwargHyperlinkedIdentityField, self).__init__(*args, **kwargs)
def get_url(self, obj, view_name, request, format):
"""
Extends HyperlinkedRelatedField to allow passing more than one view
keyword argument.
----
Given an object, return the URL that hyperlinks to the object.
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
attributes are not configured to correctly match the URL conf.
"""
# Unsaved objects will not yet have a valid URL.
if hasattr(obj, 'pk') and obj.pk in (None, ''):
return None
kwargs = {}
for entry in self.view_kwargs:
kwargs[entry['lookup_url_kwarg']] = resolve_attribute(
obj=obj, attribute=entry['lookup_field']
)
return self.reverse(
viewname=view_name, kwargs=kwargs, request=request, format=format
)

View File

@@ -1,8 +0,0 @@
from __future__ import unicode_literals
from rest_framework import serializers
class EndpointSerializer(serializers.Serializer):
label = serializers.CharField(read_only=True)
url = serializers.URLField(read_only=True)

View File

@@ -1,18 +1,24 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf.urls import include, url from django.conf.urls import url
from .api_views import APIRoot, BrowseableObtainAuthToken from .api_views import BrowseableObtainAuthToken, schema_view
api_urls = [
url(r'^$', APIRoot.as_view(), name='api_root'),
url(
r'^auth/token/obtain/$', BrowseableObtainAuthToken.as_view(),
name='auth_token_obtain'
),
]
urlpatterns = [ urlpatterns = [
url(r'^', include(api_urls)), url(
regex=r'^auth/token/obtain/$', name='auth_token_obtain',
view=BrowseableObtainAuthToken.as_view()
),
url(
regex=r'^swagger(?P<format>.json|.yaml)$', name='schema-json',
view=schema_view.without_ui(cache_timeout=None),
),
url(
regex=r'^swagger/$', name='schema-swagger-ui',
view=schema_view.with_ui('swagger', cache_timeout=None)
),
url(
regex=r'^redoc/$', name='schema-redoc',
view=schema_view.with_ui('redoc', cache_timeout=None)
),
] ]

View File

@@ -0,0 +1,11 @@
from __future__ import absolute_import, unicode_literals
from rest_framework import viewsets
from .filters import MayanViewSetObjectPermissionsFilter
from .permissions import MayanViewSetPermission
class MayanAPIModelViewSet(viewsets.ModelViewSet):
filter_backends = (MayanViewSetObjectPermissionsFilter,)
permission_classes = (MayanViewSetPermission,)

View File

@@ -3,22 +3,8 @@ from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from django.contrib import admin from django.contrib import admin
from drf_yasg.views import get_schema_view
from rest_framework import permissions
from mayan.apps.rest_api.schemas import openapi_info
admin.autodiscover() admin.autodiscover()
schema_view = get_schema_view(
openapi_info,
validators=['flex', 'ssv'],
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [ urlpatterns = [
url(r'^admin/', admin.site.urls), url(r'^admin/', admin.site.urls),
url(r'^swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema-json'),
url(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=None), name='schema-swagger-ui'),
url(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='schema-redoc'),
] ]