From fb630dabf08bfb8f7c309a048b93c024dd690799 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Fri, 14 Feb 2025 15:30:34 +0100 Subject: [PATCH] add drf-spectacular support (#1508) --- .github/ISSUE_TEMPLATE/release_cleanup.md | 2 +- CHANGELOG.rst | 3 +++ Makefile | 6 ++++++ config/drf_spectacular.py | 10 ++++++++++ config/settings/base.py | 8 +++++++- docs/source/major_changes.rst | 9 +++++++++ filesfolders/serializers.py | 6 +++++- filesfolders/views_api.py | 7 ------- projectroles/serializers.py | 4 ++-- projectroles/views_api.py | 23 ++++++++++++++++++++--- requirements/base.txt | 3 +++ sodarcache/tests/test_views_api.py | 2 ++ sodarcache/views_api.py | 14 +++++++++++++- timeline/views_api.py | 2 -- 14 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 config/drf_spectacular.py diff --git a/.github/ISSUE_TEMPLATE/release_cleanup.md b/.github/ISSUE_TEMPLATE/release_cleanup.md index 18486003..966d5468 100644 --- a/.github/ISSUE_TEMPLATE/release_cleanup.md +++ b/.github/ISSUE_TEMPLATE/release_cleanup.md @@ -27,7 +27,7 @@ TBA - [ ] Update version number and date in `CHANGELOG` - [ ] Update version number and date in `Major Changes` doc - [ ] Ensure docs can be built without errors -- [ ] Ensure `generateschema` runs without errors or warnings (until in CI) +- [ ] Ensure `make spectacular` runs without errors or warnings (until in CI) ## Notes diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7ab0841b..981d0753 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,8 @@ Unreleased Added ----- +- **General** + - ``drf-spectacular`` support (#1508) - **Projectroles** - ``SODARUser.get_display_name()`` helper (#1487) - App setting type constants (#1458) @@ -104,6 +106,7 @@ Removed - **General** - Migrations squashed in v1.0 (#1455) + - DRF ``generateschema`` support (#1508) - **Projectroles** - Support for deprecated search results as dict (#1400) - Support for deprecated app setting ``local`` parameter (#1394) diff --git a/Makefile b/Makefile index 723dd00c..c71bdfc4 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ define USAGE= @echo -e "\tmake collectstatic -- run collectstatic" @echo -e "\tmake test [arg=] -- run all tests or specify module/class/function" @echo -e "\tmake manage_target arg= -- run management command on target site, arg is mandatory" +@echo -e "\tmake spectacular -- generate OpenAPI schemas with drf-spectacular" @echo -e endef @@ -67,6 +68,11 @@ else endif +.PHONY: spectacular +spectacular: + $(MANAGE) spectacular --color $(arg) + + .PHONY: usage usage: $(USAGE) diff --git a/config/drf_spectacular.py b/config/drf_spectacular.py new file mode 100644 index 00000000..30d9bfaa --- /dev/null +++ b/config/drf_spectacular.py @@ -0,0 +1,10 @@ +"""Config for drf-spectacular""" + + +def exclude_knox_hook(endpoints): + """Exclude django-knox-auth endpoints from spectacular OpenAPI generation""" + filtered = [] + for path, path_regex, method, callback in endpoints: + if not path.startswith('/api/auth/log'): + filtered.append((path, path_regex, method, callback)) + return filtered diff --git a/config/settings/base.py b/config/settings/base.py index e5ef8977..2d052e56 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -68,6 +68,7 @@ 'dal', # For user search combo box 'dal_select2', 'dj_iconify.apps.DjIconifyConfig', # Iconify for SVG icons + 'drf_spectacular', # OpenAPI schema generation ] # Project apps @@ -332,7 +333,6 @@ # Django REST framework # ------------------------------------------------------------------------------ - REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.BasicAuthentication', @@ -343,8 +343,14 @@ 'rest_framework.pagination.PageNumberPagination' ), 'PAGE_SIZE': SODAR_API_PAGE_SIZE, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'PREPROCESSING_HOOKS': ['config.drf_spectacular.exclude_knox_hook'] } + # Additional authentication settings # ------------------------------------------------------------------------------ diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 261c782e..7ea81022 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -24,6 +24,7 @@ Release Highlights - Add app setting type constants - Add app setting definition as objects - Add API view to retrieve user details by user UUID +- Add drf-spectacular support for API documentation - Update project list for flat list display - Update owner transfer form to allow setting no role for old owner - Update app settings API @@ -37,6 +38,7 @@ Release Highlights - Remove support for sodarcache REST API Optional[uuid.UUID]: if obj.folder: return obj.folder.sodar_uuid else: diff --git a/filesfolders/views_api.py b/filesfolders/views_api.py index d81abf03..1d4e1f53 100644 --- a/filesfolders/views_api.py +++ b/filesfolders/views_api.py @@ -6,7 +6,6 @@ GenericAPIView, ) from rest_framework.renderers import JSONRenderer -from rest_framework.schemas.openapi import AutoSchema from rest_framework.versioning import AcceptHeaderVersioning # Projectroles dependency @@ -198,7 +197,6 @@ class FolderListCreateAPIView( pagination_class = SODARPageNumberPagination project_type = PROJECT_TYPE_PROJECT - schema = AutoSchema(operation_id_base='ListCreateFolder') serializer_class = FolderSerializer @@ -228,7 +226,6 @@ class FolderRetrieveUpdateDestroyAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'folder' project_type = PROJECT_TYPE_PROJECT - schema = AutoSchema(operation_id_base='UpdateDestroyFolder') serializer_class = FolderSerializer @@ -268,7 +265,6 @@ class FileListCreateAPIView( pagination_class = SODARPageNumberPagination project_type = PROJECT_TYPE_PROJECT - schema = AutoSchema(operation_id_base='ListCreateFile') serializer_class = FileSerializer @@ -300,7 +296,6 @@ class FileRetrieveUpdateDestroyAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'file' project_type = PROJECT_TYPE_PROJECT - schema = AutoSchema(operation_id_base='UpdateDestroyFile') serializer_class = FileSerializer @@ -358,7 +353,6 @@ class HyperLinkListCreateAPIView( pagination_class = SODARPageNumberPagination project_type = PROJECT_TYPE_PROJECT - schema = AutoSchema(operation_id_base='ListCreateHyperLink') serializer_class = HyperLinkSerializer @@ -389,5 +383,4 @@ class HyperLinkRetrieveUpdateDestroyAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'hyperlink' project_type = PROJECT_TYPE_PROJECT - schema = AutoSchema(operation_id_base='UpdateDestroyHyperLink') serializer_class = HyperLinkSerializer diff --git a/projectroles/serializers.py b/projectroles/serializers.py index 62b90dd2..4779cbf4 100644 --- a/projectroles/serializers.py +++ b/projectroles/serializers.py @@ -164,7 +164,7 @@ class Meta: 'sodar_uuid', ] - def get_additional_emails(self, obj): + def get_additional_emails(self, obj: User) -> list: return [ e.email for e in SODARUserAdditionalEmail.objects.filter( @@ -172,7 +172,7 @@ def get_additional_emails(self, obj): ).order_by('email') ] - def get_auth_type(self, obj): + def get_auth_type(self, obj: User) -> str: return obj.get_auth_type() def to_representation(self, instance): diff --git a/projectroles/views_api.py b/projectroles/views_api.py index 9b70fc2b..adc75bd1 100644 --- a/projectroles/views_api.py +++ b/projectroles/views_api.py @@ -33,10 +33,11 @@ ) from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.schemas.openapi import AutoSchema from rest_framework.versioning import AcceptHeaderVersioning from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, inline_serializer + from projectroles.app_settings import AppSettingAPI from projectroles.forms import INVITE_EXISTS_MSG from projectroles.models import ( @@ -684,6 +685,7 @@ class RoleAssignmentOwnerTransferAPIView( """ permission_required = 'projectroles.update_project_owner' + serializer_class = RoleAssignmentSerializer def post(self, request, *args, **kwargs): """Handle ownership transfer in a POST request""" @@ -893,6 +895,7 @@ class ProjectInviteRevokeAPIView( """ permission_required = 'projectroles.invite_users' + serializer_class = ProjectInviteSerializer def post(self, request, *args, **kwargs): """Handle invite revoking in a POST request""" @@ -928,6 +931,7 @@ class ProjectInviteResendAPIView( """ permission_required = 'projectroles.invite_users' + serializer_class = ProjectInviteSerializer def post(self, request, *args, **kwargs): """Handle invite resending in a POST request""" @@ -1105,7 +1109,6 @@ class ProjectSettingRetrieveAPIView( # NOTE: Update project settings perm is checked manually permission_required = 'projectroles.view_project' - schema = AutoSchema(operation_id_base='AppSettingProject') serializer_class = AppSettingSerializer def get_object(self): @@ -1160,6 +1163,7 @@ class ProjectSettingSetAPIView( http_method_names = ['post'] # NOTE: Update project settings perm is checked manually permission_required = 'projectroles.view_project' + serializer_class = AppSettingSerializer @transaction.atomic def post(self, request, *args, **kwargs): @@ -1276,7 +1280,6 @@ class UserSettingRetrieveAPIView( - ``setting_name``: Setting name (string) """ - schema = AutoSchema(operation_id_base='AppSettingUser') serializer_class = AppSettingSerializer def get_object(self): @@ -1313,6 +1316,7 @@ class UserSettingSetAPIView( """ http_method_names = ['post'] + serializer_class = AppSettingSerializer def post(self, request, *args, **kwargs): if not request.user.is_authenticated: @@ -1446,6 +1450,19 @@ def get_object(self): # TODO: Update this for new API base classes +@extend_schema( + responses={ + '200': inline_serializer( + 'RemoteProjectGetResponse', + fields={ + 'users': serializers.JSONField(), + 'projects': serializers.JSONField(), + 'peer_sites': serializers.JSONField(), + 'app_settings': serializers.JSONField(), + }, + ) + } +) class RemoteProjectGetAPIView(RemoteSyncAPIVersioningMixin, APIView): """API view for retrieving remote projects from a source site""" diff --git a/requirements/base.txt b/requirements/base.txt index 7863dc93..b89f82f0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -85,3 +85,6 @@ celery>=5.4.0, <5.5 # Django autocomplete light (DAL) django-autocomplete-light==3.11.0 + +# DRF-spectacular for OpenAPI schema generation +drf-spectacular>=0.28.0, <0.29 diff --git a/sodarcache/tests/test_views_api.py b/sodarcache/tests/test_views_api.py index cbc0a3d7..eb30a2f9 100644 --- a/sodarcache/tests/test_views_api.py +++ b/sodarcache/tests/test_views_api.py @@ -249,6 +249,7 @@ def test_post_create(self): response = self.request_knox(url, method='POST', data=post_data) self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {'detail': 'ok'}) self.assertEqual(JSONCacheItem.objects.all().count(), 2) item = JSONCacheItem.objects.get(name='new_test_item') expected = { @@ -277,6 +278,7 @@ def test_post_update(self): response = self.request_knox(url, method='POST', data=post_data) self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {'detail': 'ok'}) self.assertEqual(JSONCacheItem.objects.all().count(), 1) item = JSONCacheItem.objects.get(name=ITEM_NAME) expected = { diff --git a/sodarcache/views_api.py b/sodarcache/views_api.py index 32d06376..78f3226f 100644 --- a/sodarcache/views_api.py +++ b/sodarcache/views_api.py @@ -1,5 +1,6 @@ """REST API views for the sodarcache app""" +from rest_framework import serializers from rest_framework.exceptions import APIException, NotFound, ParseError from rest_framework.generics import RetrieveAPIView from rest_framework.renderers import JSONRenderer @@ -7,6 +8,8 @@ from rest_framework.versioning import AcceptHeaderVersioning from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, inline_serializer + # Projectroles dependency from projectroles.models import SODAR_CONSTANTS from projectroles.plugins import get_backend_api @@ -113,6 +116,14 @@ def get_object(self): return item +@extend_schema( + responses={ + '200': inline_serializer( + 'UpdateTimeResponse', + fields={'update_time': serializers.IntegerField()}, + ) + } +) class CacheItemDateRetrieveAPIView( SodarcacheAPIViewMixin, SODARAPIGenericProjectMixin, APIView ): @@ -165,9 +176,10 @@ class CacheItemSetAPIView( - ``data``: Full item data to be set (JSON) """ + http_method_names = ['post'] permission_required = 'sodarcache.set_cache_value' project_type = PROJECT_TYPE_PROJECT - http_method_names = ['post'] + serializer_class = JSONCacheItemSerializer def post(self, request, *args, **kwargs): cache_backend = self.get_backend() diff --git a/timeline/views_api.py b/timeline/views_api.py index 546d3e6f..8ca10367 100644 --- a/timeline/views_api.py +++ b/timeline/views_api.py @@ -4,7 +4,6 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer -from rest_framework.schemas.openapi import AutoSchema from rest_framework.versioning import AcceptHeaderVersioning # Projectroles dependency @@ -64,7 +63,6 @@ class ProjectTimelineEventListAPIView( pagination_class = SODARPageNumberPagination permission_required = 'timeline.view_timeline' - schema = AutoSchema(operation_id_base='ListTimelineEvent') serializer_class = TimelineEventSerializer def get_queryset(self):