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:
@@ -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,),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
48
mayan/apps/rest_api/generics.py
Normal file
48
mayan/apps/rest_api/generics.py
Normal 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,)
|
||||||
@@ -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'
|
||||||
)
|
)
|
||||||
|
|||||||
54
mayan/apps/rest_api/mixins.py
Normal file
54
mayan/apps/rest_api/mixins.py
Normal 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
|
||||||
@@ -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
|
|
||||||
|
|||||||
35
mayan/apps/rest_api/relations.py
Normal file
35
mayan/apps/rest_api/relations.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
11
mayan/apps/rest_api/viewsets.py
Normal file
11
mayan/apps/rest_api/viewsets.py
Normal 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,)
|
||||||
@@ -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'),
|
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user