From 627056f1ae8ce71fd38f68727d41da52ae5b4805 Mon Sep 17 00:00:00 2001 From: Roberto Rosario Date: Wed, 6 Feb 2019 05:12:14 -0400 Subject: [PATCH] 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 --- mayan/apps/rest_api/api_views.py | 36 ++++++------------ mayan/apps/rest_api/apps.py | 14 +++++-- mayan/apps/rest_api/classes.py | 10 ----- mayan/apps/rest_api/filters.py | 29 +++++++++----- mayan/apps/rest_api/generics.py | 48 +++++++++++++++++++++++ mayan/apps/rest_api/links.py | 6 +-- mayan/apps/rest_api/mixins.py | 54 ++++++++++++++++++++++++++ mayan/apps/rest_api/permissions.py | 61 +++++++++--------------------- mayan/apps/rest_api/relations.py | 35 +++++++++++++++++ mayan/apps/rest_api/serializers.py | 8 ---- mayan/apps/rest_api/urls.py | 30 +++++++++------ mayan/apps/rest_api/viewsets.py | 11 ++++++ mayan/urls/base.py | 14 ------- 13 files changed, 228 insertions(+), 128 deletions(-) delete mode 100644 mayan/apps/rest_api/classes.py create mode 100644 mayan/apps/rest_api/generics.py create mode 100644 mayan/apps/rest_api/mixins.py create mode 100644 mayan/apps/rest_api/relations.py delete mode 100644 mayan/apps/rest_api/serializers.py create mode 100644 mayan/apps/rest_api/viewsets.py diff --git a/mayan/apps/rest_api/api_views.py b/mayan/apps/rest_api/api_views.py index ee1dd41c94..bad68a2f5f 100644 --- a/mayan/apps/rest_api/api_views.py +++ b/mayan/apps/rest_api/api_views.py @@ -1,31 +1,11 @@ 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.views import APIView -from rest_framework.response import Response -from rest_framework.schemas.generators import EndpointEnumerator -from .classes import Endpoint -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) +from .schemas import openapi_info class BrowseableObtainAuthToken(ObtainAuthToken): @@ -33,3 +13,11 @@ class BrowseableObtainAuthToken(ObtainAuthToken): Obtain an API authentication token. """ renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer) + + +schema_view = get_schema_view( + openapi_info, + validators=['flex', 'ssv'], + public=True, + permission_classes=(permissions.AllowAny,), +) diff --git a/mayan/apps/rest_api/apps.py b/mayan/apps/rest_api/apps.py index 88d0b2c61a..d913ccd02c 100644 --- a/mayan/apps/rest_api/apps.py +++ b/mayan/apps/rest_api/apps.py @@ -5,6 +5,8 @@ from django.conf import settings from django.utils.module_loading import import_string from django.utils.translation import ugettext_lazy as _ +from rest_framework import routers + from mayan.apps.common import MayanAppConfig, menu_tools from .links import ( @@ -21,7 +23,7 @@ class RESTAPIApp(MayanAppConfig): def ready(self): super(RESTAPIApp, self).ready() - from .urls import api_urls + from .urls import urlpatterns settings.STRONGHOLD_PUBLIC_URLS += (r'^/%s/.+$' % self.app_url,) menu_tools.bind_links( @@ -29,8 +31,14 @@ class RESTAPIApp(MayanAppConfig): link_api, link_api_documentation, link_api_documentation_redoc ) ) + router = routers.DefaultRouter() for app in apps.get_app_configs(): if getattr(app, 'has_rest_api', False): - app_api_urls = import_string('{}.urls.api_urls'.format(app.name)) - api_urls.extend(app_api_urls) + try: + for entry in import_string('{}.urls.api_router_entries'.format(app.name)): + router.register(**entry) + except ImportError: + pass + + urlpatterns.extend(router.urls) diff --git a/mayan/apps/rest_api/classes.py b/mayan/apps/rest_api/classes.py deleted file mode 100644 index 4cb84795a8..0000000000 --- a/mayan/apps/rest_api/classes.py +++ /dev/null @@ -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) diff --git a/mayan/apps/rest_api/filters.py b/mayan/apps/rest_api/filters.py index 1b561c598e..928c997bc8 100644 --- a/mayan/apps/rest_api/filters.py +++ b/mayan/apps/rest_api/filters.py @@ -5,18 +5,27 @@ from rest_framework.filters import BaseFilterBackend from mayan.apps.acls.models import AccessControlList -class MayanObjectPermissionsFilter(BaseFilterBackend): +class MayanViewSetObjectPermissionsFilter(BaseFilterBackend): 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( - view, 'mayan_object_permissions', {} - ).get(request.method, None) - - if required_permissions: - return AccessControlList.objects.filter_by_access( - required_permissions[0], request.user, queryset=queryset + if object_permission: + return AccessControlList.objects.restrict_queryset( + permission=object_permission, queryset=queryset, + user=request.user ) else: return queryset diff --git a/mayan/apps/rest_api/generics.py b/mayan/apps/rest_api/generics.py new file mode 100644 index 0000000000..ec3cf267d3 --- /dev/null +++ b/mayan/apps/rest_api/generics.py @@ -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,) diff --git a/mayan/apps/rest_api/links.py b/mayan/apps/rest_api/links.py index efd5443ab3..d181599268 100644 --- a/mayan/apps/rest_api/links.py +++ b/mayan/apps/rest_api/links.py @@ -11,14 +11,14 @@ from .icons import ( link_api = Link( icon_class=icon_api, tags='new_window', text=_('REST API'), - view='rest_api:api_root' + view='rest_api:api-root' ) link_api_documentation = Link( 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( 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' ) diff --git a/mayan/apps/rest_api/mixins.py b/mayan/apps/rest_api/mixins.py new file mode 100644 index 0000000000..efacb23ac1 --- /dev/null +++ b/mayan/apps/rest_api/mixins.py @@ -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 diff --git a/mayan/apps/rest_api/permissions.py b/mayan/apps/rest_api/permissions.py index 476b551a21..830c4e5a07 100644 --- a/mayan/apps/rest_api/permissions.py +++ b/mayan/apps/rest_api/permissions.py @@ -1,26 +1,31 @@ -from __future__ import absolute_import - -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from django.core.exceptions import PermissionDenied -from django.http import Http404 from rest_framework.permissions import BasePermission -from mayan.apps.acls.models import AccessControlList from mayan.apps.permissions import Permission -class MayanPermission(BasePermission): +class MayanViewSetPermission(BasePermission): def has_permission(self, request, view): - required_permission = getattr( - view, 'mayan_view_permissions', {} - ).get(request.method, None) + """ + Block the API view by access using a permission. + 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: - Permission.check_permissions( - requester=request.user, permissions=required_permission + Permission.check_user_permission( + permission=view_permission, user=request.user ) except PermissionDenied: return False @@ -28,35 +33,3 @@ class MayanPermission(BasePermission): return True else: 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 diff --git a/mayan/apps/rest_api/relations.py b/mayan/apps/rest_api/relations.py new file mode 100644 index 0000000000..5ff9f24f75 --- /dev/null +++ b/mayan/apps/rest_api/relations.py @@ -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 + ) diff --git a/mayan/apps/rest_api/serializers.py b/mayan/apps/rest_api/serializers.py deleted file mode 100644 index 041fc524a4..0000000000 --- a/mayan/apps/rest_api/serializers.py +++ /dev/null @@ -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) diff --git a/mayan/apps/rest_api/urls.py b/mayan/apps/rest_api/urls.py index 33a3a7bed8..a9101bb273 100644 --- a/mayan/apps/rest_api/urls.py +++ b/mayan/apps/rest_api/urls.py @@ -1,18 +1,24 @@ from __future__ import unicode_literals -from django.conf.urls import include, url +from django.conf.urls import url -from .api_views import APIRoot, BrowseableObtainAuthToken - - -api_urls = [ - url(r'^$', APIRoot.as_view(), name='api_root'), - url( - r'^auth/token/obtain/$', BrowseableObtainAuthToken.as_view(), - name='auth_token_obtain' - ), -] +from .api_views import BrowseableObtainAuthToken, schema_view 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.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) + ), ] diff --git a/mayan/apps/rest_api/viewsets.py b/mayan/apps/rest_api/viewsets.py new file mode 100644 index 0000000000..3369f1350d --- /dev/null +++ b/mayan/apps/rest_api/viewsets.py @@ -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,) diff --git a/mayan/urls/base.py b/mayan/urls/base.py index 7e3a9a143d..6681443287 100644 --- a/mayan/urls/base.py +++ b/mayan/urls/base.py @@ -3,22 +3,8 @@ from __future__ import unicode_literals from django.conf.urls import url 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() -schema_view = get_schema_view( - openapi_info, - validators=['flex', 'ssv'], - public=True, - permission_classes=(permissions.AllowAny,), -) urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^swagger(?P.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'), ]