diff --git a/.isort.cfg b/.isort.cfg index 27b313cba6fee655b44650d0dd71adf123123ca3..b07c8654e194642f00a0bb862f84fda86f09255a 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -8,4 +8,4 @@ line_length = 120 default_section=FIRSTPARTY known_first_party = arkindex_common,ponos,transkribus -known_third_party = boto3,botocore,corsheaders,django,django_admin_hstore_widget,django_rq,elasticsearch,elasticsearch_dsl,enumfields,gitlab,psycopg2,requests,responses,rest_framework,rq,setuptools,sqlparse,teklia_toolbox,tenacity,tripoli,yaml +known_third_party = boto3,botocore,corsheaders,django,django_admin_hstore_widget,django_rq,drf_spectacular,elasticsearch,elasticsearch_dsl,enumfields,gitlab,psycopg2,requests,responses,rest_framework,rq,setuptools,sqlparse,teklia_toolbox,tenacity,tripoli,yaml diff --git a/Makefile b/Makefile index 357833eb7dba9a42719fb94f3526ea3d805f1925..1af263ad8d1d3af189f49a6594e6b26b739dbc84 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ require-version: @git rev-parse $(version) >/dev/null 2>&1 && (echo "Version $(version) already exists on local git repo !" && exit 1) || true schema: - ./arkindex/manage.py generateschema --generator_class arkindex.project.openapi.SchemaGenerator > schema.yml + ./arkindex/manage.py spectacular --fail-on-warn --validate --file schema.yml release: $(eval version:=$(shell cat VERSION)) diff --git a/arkindex/dataimport/api.py b/arkindex/dataimport/api.py index e74f99869caf93d519dd2445d0072444d3d1c1a7..5dbd90974f4f17256c982da858b111490220c3ad 100644 --- a/arkindex/dataimport/api.py +++ b/arkindex/dataimport/api.py @@ -7,6 +7,7 @@ from django.db import transaction from django.db.models import Count, F, Max, Q from django.http.response import Http404 from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import permissions, status from rest_framework.exceptions import ValidationError from rest_framework.generics import ( @@ -38,16 +39,15 @@ from arkindex.dataimport.serializers.imports import ( DataImportFromFilesSerializer, DataImportLightSerializer, DataImportSerializer, - ElementLightSerializer, ElementsWorkflowSerializer, ImportTranskribusSerializer, + ProcessElementSerializer, StartProcessSerializer, WorkerRunLightSerializer, WorkerRunSerializer, ) from arkindex.dataimport.serializers.workers import RepositorySerializer, WorkerSerializer, WorkerVersionSerializer -from arkindex.documents.api.elements import ElementsListMixin -from arkindex.documents.models import Corpus, ElementType +from arkindex.documents.models import Corpus, Element, ElementType from arkindex.project.fields import ArrayRemove from arkindex.project.mixins import ( CorpusACLMixin, @@ -56,7 +56,6 @@ from arkindex.project.mixins import ( RepositoryACLMixin, SelectionMixin, ) -from arkindex.project.openapi import AutoSchema from arkindex.project.permissions import IsVerified, IsVerifiedOrReadOnly from arkindex.users.models import OAuthCredentials, Role, User from arkindex_common.enums import DataImportMode @@ -65,65 +64,48 @@ from ponos.models import STATES_ORDERING, State logger = logging.getLogger(__name__) +@extend_schema_view( + get=extend_schema( + operation_id='ListDataImports', + tags=['imports'], + parameters=[ + OpenApiParameter( + 'corpus', + type=UUID, + description="Filter imports by corpus ID", + required=False + ), + OpenApiParameter( + 'mode', + enum=[mode.value for mode in DataImportMode], + description="Filter imports by mode", + required=False, + ), + OpenApiParameter( + 'state', + enum=[state.value for state in State], + description='Filter imports by workflow state', + required=False, + ), + OpenApiParameter( + 'id', + description='Filter imports by beginning of UUID', + required=False, + ), + OpenApiParameter( + 'with_workflow', + type=bool, + description='Restrict to or exclude imports with workflows', + ) + ] + ) +) class DataImportsList(CorpusACLMixin, ListAPIView): """ List all data imports """ permission_classes = (IsVerified, ) serializer_class = DataImportLightSerializer - openapi_overrides = { - 'operationId': 'ListDataImports', - 'tags': ['imports'], - 'parameters': [ - { - 'name': 'corpus', - 'in': 'query', - 'description': 'Filter imports by corpus ID', - 'required': False, - 'schema': { - 'type': 'string', - 'format': 'uuid', - } - }, - { - 'name': 'mode', - 'in': 'query', - 'description': 'Filter imports by mode', - 'required': False, - 'schema': { - 'enum': [mode.value for mode in DataImportMode], - }, - }, - { - 'name': 'state', - 'in': 'query', - 'description': 'Filter imports by workflow state', - 'required': False, - 'schema': { - 'enum': [state.value for state in State], - }, - }, - { - 'name': 'id', - 'in': 'query', - 'description': 'Filter imports by beginning of UUID', - 'required': False, - 'schema': { - 'type': 'string', - }, - }, - { - 'name': 'with_workflow', - 'in': 'query', - 'description': 'Restrict to or exclude import with workflow', - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': True - }, - } - ] - } def get_queryset(self): with_workflow = self.request.query_params.get('with_workflow') @@ -242,14 +224,10 @@ class DataImportDetails(CorpusACLMixin, RetrieveUpdateDestroyAPIView): class DataImportRetry(CorpusACLMixin, GenericAPIView): """ - Retry a data import. Can only be used on imports with Error, Failed or Stopped states. + Retry a data import. Can only be used on imports with Error, Failed, Stopped or Completed states. """ permission_classes = (IsVerified, ) serializer_class = DataImportSerializer - openapi_overrides = { - 'operationId': 'RetryDataImport', - 'tags': ['imports'], - } def get_queryset(self): return DataImport.objects.filter( @@ -257,6 +235,11 @@ class DataImportRetry(CorpusACLMixin, GenericAPIView): | Q(corpus__in=Corpus.objects.writable(self.request.user)) ) + @extend_schema( + operation_id='RetryDataImport', + tags=['imports'], + request=None, + ) def post(self, request, *args, **kwargs): instance = self.get_object() if not instance.corpus: @@ -316,25 +299,22 @@ class DataImportFromFiles(CreateAPIView): self.dataimport.start(thumbnails=True) +@extend_schema_view( + post=extend_schema( + operation_id='CreateElementsWorkflow', + tags=['imports'], + responses={201: DataImportSerializer}, + ) +) class CorpusWorkflow(SelectionMixin, CreateAPIView): """ - Start a Worker Process from Arkindex corpus elements + Create a distributed workflow from elements of an Arkindex corpus """ permission_classes = (IsVerified, ) serializer_class = ElementsWorkflowSerializer openapi_overrides = { 'operationId': 'CreateElementsWorkflow', - 'description': 'Create a distributed workflow from elements of an Arkindex corpus', 'tags': ['imports'], - 'responses': { - '201': { - 'content': { - 'application/json': { - 'schema': AutoSchema()._map_serializer(DataImportSerializer()) - } - } - } - } } def create(self, request, pk=None, **kwargs): @@ -394,11 +374,15 @@ class StartProcess(APIView): Start a process, used to build a Workflow with Workers """ permission_classes = (IsVerified, ) - openapi_overrides = { - 'operationId': 'StartProcess', - 'tags': ['imports'], - } - + # For OpenAPI type discovery + queryset = DataImport.objects.none() + + @extend_schema( + operation_id='StartProcess', + tags=['imports'], + request=StartProcessSerializer, + responses=DataImportSerializer + ) def post(self, request, pk=None, **kwargs): dataimport = get_object_or_404(DataImport, pk=self.kwargs['pk']) @@ -419,13 +403,16 @@ class StartProcess(APIView): class DataImportElements(DeprecatedMixin, ListAPIView): - openapi_overrides = ElementsListMixin.openapi_overrides.copy() - openapi_overrides.update({ + """ + List a DataImport's associated elements, + for DataImports running workers on existing elements. + """ + openapi_overrides = { 'operationId': 'ListDataImportElements', 'tags': ['imports'], - 'description': "List a DataImport's associated elements, " - "for DataImports running workers on existing elements.", - }) + } + # For OpenAPI type discovery + queryset = Element.objects.none() deprecation_message = 'Listing elements on a DataImport is now implemented through ListProcessElements.' @@ -440,6 +427,8 @@ class DataFileList(CorpusACLMixin, ListAPIView): openapi_overrides = { 'tags': ['files'] } + # For OpenAPI type discovery + queryset = DataFile.objects.none() def get_queryset(self): return DataFile.objects.filter(corpus=self.get_corpus(self.kwargs['pk'])) @@ -485,6 +474,8 @@ class DataFileUpload(DeprecatedMixin, CreateAPIView): 'operationId': 'UploadDataFile', 'tags': ['files'], } + # For OpenAPI type discovery + queryset = DataFile.objects.none() deprecation_message = 'DataFile uploads via the backend are now deprecated. ' \ 'Please use `CreateDataFile`, upload to S3 using the returned `s3_put_url`, ' \ 'then use `PartialUpdateDataFile` to set its status to `checked`.' @@ -504,15 +495,11 @@ class DataFileCreate(CreateAPIView): } +@extend_schema(exclude=True) class GitRepositoryImportHook(APIView): """ This endpoint is intended as a webhook for Git repository hosting applications like GitLab. """ - openapi_overrides = { - 'operationId': 'GitPushHook', - 'security': [], - 'tags': ['repos'], - } def post(self, request, pk=None, **kwargs): repo = get_object_or_404(Repository, id=pk) @@ -540,21 +527,36 @@ class RepositoryList(RepositoryACLMixin, ListAPIView): .order_by('url') +@extend_schema_view( + get=extend_schema( + operation_id='ListExternalRepositories', + parameters=[ + OpenApiParameter( + 'search', + description='Optional query terms to filter repositories', + ) + ], + tags=['repos'], + ), + post=extend_schema( + operation_id='CreateExternalRepository', + description='Using the given OAuth credentials, this links an external Git repository ' + 'to Arkindex, connects a push hook and starts an initial import.', + tags=['repos'], + ) +) class AvailableRepositoriesList(ListCreateAPIView): """ List repositories associated to user OAuth credentials + Using the given OAuth credentials ID, this uses the Git hosting application API's search feature to look for a repository matching the given query. Without a query, returns a full list. - - A POST request allow to create the repository in Arkindex and start a dataimport """ permission_classes = (IsVerified, ) pagination_class = None serializer_class = ExternalRepositorySerializer - openapi_overrides = { - 'tags': ['repos'], - } + queryset = OAuthCredentials.objects.none() def get_queryset(self): cred = get_object_or_404(OAuthCredentials, user=self.request.user, id=self.kwargs['pk']) @@ -648,7 +650,6 @@ class MLToolList(DeprecatedMixin, ListAPIView): """ openapi_overrides = { 'operationId': 'ListMLTools', - 'security': [], 'tags': ['ml'], } deprecation_message = 'Listing ML Tools is now deprecated, please use the new worker system.' @@ -667,6 +668,7 @@ class WorkerList(ListCreateAPIView): openapi_overrides = { 'tags': ['repos'], } + queryset = Worker.objects.none() def get_queryset(self): return Worker.objects.filter(repository_id=self.kwargs['pk']) @@ -703,6 +705,7 @@ class WorkerVersionList(ListCreateAPIView): openapi_overrides = { 'tags': ['repos'], } + queryset = WorkerVersion.objects.none() def get_queryset(self): return WorkerVersion.objects \ @@ -746,6 +749,8 @@ class CorpusWorkerVersionList(CorpusACLMixin, ListAPIView): 'operationId': 'ListCorpusWorkerVersions', 'tags': ['ml'], } + # For OpenAPI type discovery + queryset = WorkerVersion.objects.none() def get_queryset(self): return WorkerVersion.objects \ @@ -799,6 +804,7 @@ class WorkerRunList(ListCreateAPIView): openapi_overrides = { 'tags': ['imports'], } + queryset = WorkerRun.objects.none() def get_queryset(self): return WorkerRun.objects.filter( @@ -849,19 +855,12 @@ class ImportTranskribus(CreateAPIView): serializer_class = ImportTranskribusSerializer openapi_overrides = { 'operationId': 'CreateImportTranskribus', - 'description': 'Create a data import from Transkribus collection ID.', 'tags': ['imports'], - 'responses': { - '201': { - 'content': { - 'application/json': { - 'schema': AutoSchema()._map_serializer(DataImportSerializer()) - } - } - } - } } + @extend_schema( + responses={201: DataImportSerializer} + ) def create(self, *args, **kwargs): if not settings.ARKINDEX_FEATURES['transkribus']: raise ValidationError(['Transkribus import is unavailable due to the transkribus feature being disabled.']) @@ -896,11 +895,13 @@ class ListProcessElements(CustomPaginationViewMixin, ListAPIView): List all elements for a specific process """ permission_classes = (IsVerified, ) - serializer_class = ElementLightSerializer + serializer_class = ProcessElementSerializer openapi_overrides = { 'operationId': 'ListProcessElements', 'tags': ['imports'] } + # For OpenAPI type discovery + queryset = Element.objects.none() def get_queryset(self): dataimport = get_object_or_404( diff --git a/arkindex/dataimport/models.py b/arkindex/dataimport/models.py index d27473f2d6fae62c066875e17904db7cb88bf6db..f4422d45ad101a73a6c7c460aa2e93c16d48652e 100644 --- a/arkindex/dataimport/models.py +++ b/arkindex/dataimport/models.py @@ -494,7 +494,7 @@ class WorkerVersion(models.Model): return f'{self.worker} for revision {self.revision}' @property - def docker_image_name(self): + def docker_image_name(self) -> str: parsed_url = urllib.parse.urlparse(self.worker.repository.url) return f'{parsed_url.netloc}{parsed_url.path}/{self.worker.slug}:{self.id}'.lower() diff --git a/arkindex/dataimport/serializers/files.py b/arkindex/dataimport/serializers/files.py index e353854a0e3c6477dc4d0d41a5d327ea8bc667df..b860f270a338f9b441a40c1abf27c5c2d3631841 100644 --- a/arkindex/dataimport/serializers/files.py +++ b/arkindex/dataimport/serializers/files.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from arkindex.dataimport.models import DataFile @@ -35,6 +36,7 @@ class DataFileSerializer(serializers.ModelSerializer): raise serializers.ValidationError(str(e)) return value + @extend_schema_field(serializers.CharField(allow_null=True)) def get_s3_url(self, obj): if 'request' not in self.context: return @@ -74,6 +76,7 @@ class DataFileCreateSerializer(serializers.ModelSerializer): return self.fields['corpus'].queryset = Corpus.objects.writable(self.context['request'].user) + @extend_schema_field(serializers.CharField(allow_null=True)) def get_s3_put_url(self, obj): if obj.status == S3FileStatus.Checked: return None diff --git a/arkindex/dataimport/serializers/imports.py b/arkindex/dataimport/serializers/imports.py index ffc09debdc1f5e47d99e9d2c9faa0452ae06cbc7..4341c9a19cd77b8d5972f595be48ad1a27939b5c 100644 --- a/arkindex/dataimport/serializers/imports.py +++ b/arkindex/dataimport/serializers/imports.py @@ -373,7 +373,7 @@ class ImportTranskribusSerializer(serializers.Serializer): return data -class ElementLightSerializer(serializers.ModelSerializer): +class ProcessElementSerializer(serializers.ModelSerializer): """ Serialises an Element, using optimized query for ListProcessElement """ diff --git a/arkindex/dataimport/serializers/workers.py b/arkindex/dataimport/serializers/workers.py index e97dc63a94d31e803ac6f946540ca07c10c86860..61ba5264e2a029c971d3102cb28a6570808e9e7e 100644 --- a/arkindex/dataimport/serializers/workers.py +++ b/arkindex/dataimport/serializers/workers.py @@ -1,5 +1,6 @@ import urllib +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -88,6 +89,7 @@ class RepositorySerializer(serializers.ModelSerializer): type = EnumField(RepositoryType) authorized_users = serializers.SerializerMethodField(read_only=True) + @extend_schema_field(serializers.CharField(allow_null=True)) def get_git_clone_url(self, repository): # This check avoid to set git_clone_url when this serializer is used to list multiple # repositories because self.instance value would be a list, even if the request user is internal @@ -96,7 +98,7 @@ class RepositorySerializer(serializers.ModelSerializer): return f"https://oauth2:{repository.credentials.token}@{url.netloc}{url.path}" return None - def get_authorized_users(self, repo): + def get_authorized_users(self, repo) -> int: count = getattr(repo, 'authorized_users', None) if count is None: count = repo.memberships.count() diff --git a/arkindex/documents/api/admin.py b/arkindex/documents/api/admin.py index b08091dbfb545f3efa2043c23bca3c2a0f6c5aa4..42724cb7e26eba9e266f853e2863423162df725c 100644 --- a/arkindex/documents/api/admin.py +++ b/arkindex/documents/api/admin.py @@ -8,13 +8,12 @@ from arkindex.project.permissions import IsAdminUser class ReindexStart(CreateAPIView): """ - Run an ElasticSearch indexation from the API. + Manually reindex elements, transcriptions and entities for search APIs """ permission_classes = (IsAdminUser, ) serializer_class = ReindexConfigSerializer openapi_overrides = { 'operationId': 'Reindex', - 'description': 'Manually reindex elements, transcriptions and entities for search APIs', 'tags': ['management'] } diff --git a/arkindex/documents/api/elements.py b/arkindex/documents/api/elements.py index c187ecfd790c5a3600c0dca1afc4bef904e16fd0..edf8dd100c84dbb067b8a844a7da1a29717f6c3a 100644 --- a/arkindex/documents/api/elements.py +++ b/arkindex/documents/api/elements.py @@ -2,6 +2,7 @@ import email.utils import uuid from collections import defaultdict from datetime import datetime, timezone +from uuid import UUID from django.conf import settings from django.db import connection, transaction @@ -9,6 +10,7 @@ from django.db.models import CharField, Count, Max, Prefetch, Q, QuerySet from django.db.models.functions import Cast from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer from psycopg2.extras import execute_values from rest_framework import permissions, serializers, status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError @@ -94,7 +96,7 @@ def _fetch_has_children(elements): With direct children (the last element in a path), Postgres cannot use the GIN index on `path` and uses a sequential scan and a hash join. - The ElementsListMixin uses arkindex.project.tools.BulkMap to apply this method and perform the second request + The ElementsListBase uses arkindex.project.tools.BulkMap to apply this method and perform the second request *after* DRF's pagination, because there is no way to perform post-processing after pagination in Django without having to use Django private methods. """ @@ -118,163 +120,136 @@ def _fetch_has_children(elements): return elements -class ElementsListMixin(object): +class ElementsListAutoSchema(AutoSchema): """ - Provides common features across element list endpoints (list, parents, children) + AutoSchema subclass that adds common parameters for all element list endpoints. + This is not doable using @extend_schema as this causes the same schema to be reused for each endpoint + subclassing ElementsListBase, causing duplicate operation IDs and invalid parameters. """ - serializer_class = ElementListSerializer - queryset = Element.objects.all() - permission_classes = (IsVerifiedOrReadOnly, ) - openapi_overrides = { - 'security': [], - 'tags': ['elements'], - 'parameters': [ - { - 'name': 'type', - 'in': 'query', - 'description': 'Filter elements by type', - 'required': False, - 'schema': { - 'type': 'string', - }, - }, - { - 'name': 'name', - 'in': 'query', - 'description': 'Only include elements whose name contains a given string (case-insensitive)', - 'required': False, - 'schema': {'type': 'string'}, - }, - { - 'name': 'best_class', - 'in': 'query', - 'description': 'Restrict to or exclude elements with best classes, ' - 'or restrict to elements with a specific best class', - 'required': False, - 'schema': {'anyOf': [ - {'type': 'string', 'format': 'uuid'}, - {'type': 'boolean'}, - ]}, - 'x-methods': ['get'], - }, - { - 'name': 'folder', - 'in': 'query', - 'description': 'Restrict to or exclude elements with folder types', - 'required': False, - 'schema': { - 'type': 'boolean', - } - }, - { - 'name': 'worker_version', - 'in': 'query', - 'description': 'Only include elements created by a specific worker version', - 'required': False, - 'schema': { - 'type': 'string', - 'format': 'uuid', - } - }, - { - 'name': 'with_best_classes', - 'in': 'query', - 'description': ( - 'Returns best classifications for each element. ' - 'If not set, elements best_classes field will always be null' + + def get_override_parameters(self): + # Parameters that apply to all methods + parameters = [ + OpenApiParameter( + 'type', + description='Filter elements by type', + required=False, + ), + OpenApiParameter( + 'name', + description='Only include elements whose name contains a given string (case-insensitive)', + required=False, + ), + OpenApiParameter( + 'folder', + description='Restrict to or exclude elements with folder types', + type=bool, + required=False, + ), + OpenApiParameter( + 'worker_version', + description='Only include elements created by a specific worker version', + type=UUID, + required=False, + ), + ] + + # Add method-specific parameters + if self.method.lower() == 'get': + parameters.extend([ + OpenApiParameter( + 'best_class', + description='Restrict to or exclude elements with a best class, ' + 'or restrict to elements with specific best class', + type={ + 'oneOf': [ + {'type': 'string', 'format': 'uuid'}, + {'type': 'boolean'} + ] + } ), - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': False, - }, - 'x-methods': ['get'], - }, - { - 'name': 'with_has_children', - 'in': 'query', - 'description': ( - 'Include the `has_children` boolean to tell if each element has direct children. ' - 'Otherwise, `has_children` will always be null.' + OpenApiParameter( + 'with_best_classes', + description='Returns best classifications for each element. ' + 'If not set, elements best_classes field will always be null', + type=bool, + required=False, ), - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': False - }, - 'x-methods': ['get'], - }, - { - 'name': 'with_zone', - 'in': 'query', - 'description': ( - 'Returns zone attribute for each element. ' - 'If not set, elements zone field will be returned by default.' + OpenApiParameter( + 'with_has_children', + description='Include the `has_children` boolean to tell if each element has direct children. ' + 'Otherwise, `has_children` will always be null.', + type=bool, + required=False, ), - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': True - }, - 'x-methods': ['get'], - }, - { - 'name': 'with_corpus', - 'in': 'query', - 'description': ( - 'Returns corpus attribute for each element. ' - 'If not set, elements corpus field will be returned by default.' + OpenApiParameter( + 'with_corpus', + description='Returns the corpus attribute for each element. ' + 'When not set, the corpus attribute will be returned by default.', + type={ + 'type': 'boolean', + 'default': True + }, + required=False, ), - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': True - }, - 'x-methods': ['get'], - }, - { - 'name': 'If-Modified-Since', - # Name for APIStar because dashes in Python kwargs would not go well - 'x-name': 'modified_since', - 'required': False, - 'in': 'header', - 'description': ( - 'Return HTTP 304 Not Modified when no elements have been created or updated since that time, ' - 'otherwise respond normally. Expects HTTP date formats (`Wed, 21 Oct 2015 07:28:00 GMT`). ' - 'Note that this is unable to detect deletions and will not filter the returned elements.' + OpenApiParameter( + 'with_zone', + description='Returns the zone attribute for each element. ' + 'When not set, the zone attribute will be returned by default.', + type={ + 'type': 'boolean', + 'default': True + }, + required=False, ), - 'schema': { - # Cannot use format: date-time here as HTTP headers do not use ISO 8601 - 'type': 'string' - }, - 'x-methods': ['get'], - }, - { - 'name': 'delete_children', - 'required': False, - 'in': 'query', - 'description': 'Delete all child elements of those elements recursively.', - 'schema': { - 'type': 'boolean', - 'default': True - }, - 'x-methods': ['delete'], - } - ] - } + OpenApiParameter( + 'If-Modified-Since', + location=OpenApiParameter.HEADER, + description='Return HTTP 304 Not Modified when no elements have been created or updated since that time, ' + 'otherwise respond normally. Expects HTTP date formats (`Wed, 21 Oct 2015 07:28:00 GMT`). ' + 'Note that this is unable to detect deletions and will not filter the returned elements.', + required=False + ) + ]) + + elif self.method.lower() == 'delete': + parameters.extend([ + OpenApiParameter( + 'delete_children', + description='Delete all child elements of those elements recursively.', + type=bool, + required=False, + ) + ]) + + return parameters + + def get_tags(self): + return ['elements'] + + +class ElementsListBase(CorpusACLMixin, DestroyModelMixin, ListAPIView): + """ + Provides common features across element list endpoints (list, parents, children) + """ + schema = ElementsListAutoSchema() + serializer_class = ElementListSerializer + queryset = Element.objects.all() + permission_classes = (IsVerifiedOrReadOnly, ) def initial(self, *args, **kwargs): """ Adds a `self.clean_params` attribute that holds any query params or headers, filtered by their allowed HTTP method. - Raise HTTP 400 early if any filter is not allowed in this method. Does not perform validation on the filter values. """ super().initial(*args, **kwargs) self.clean_params = {} - errors = {} - for param in self.openapi_overrides['parameters']: + # Set attributes for Spectacular's schema generation + self.schema.path = '' + self.schema.method = self.request.method + for param in self.schema._get_parameters(): if param['in'] == 'header': origin_dict = self.request.headers elif param['in'] == 'query': @@ -282,17 +257,9 @@ class ElementsListMixin(object): else: raise NotImplementedError - if param['name'] not in origin_dict: - continue - - if param.get('x-methods') and self.request.method.lower() not in param['x-methods']: - errors[param['name']] = [f'This parameter is not allowed in {self.request.method} requests.'] - else: + if param['name'] in origin_dict: self.clean_params[param['name']] = origin_dict[param['name']] - if errors: - raise ValidationError(errors) - @cached_property def selected_corpus(self): corpus_id = self.kwargs.get('corpus') @@ -454,23 +421,24 @@ class ElementsListMixin(object): return Response(status=status.HTTP_204_NO_CONTENT) -class CorpusElements(ElementsListMixin, CorpusACLMixin, DestroyModelMixin, ListAPIView): +@extend_schema( + parameters=[ + OpenApiParameter( + 'top_level', + description='Only include folder elements without parent elements (top-level elements).', + type=bool, + required=False, + ) + ] +) +@extend_schema_view( + get=extend_schema(operation_id='ListElements'), + delete=extend_schema(operation_id='DestroyElements', description='Destroy elements in bulk'), +) +class CorpusElements(ElementsListBase): """ List elements in a corpus and filter by type, name, ML class """ - openapi_overrides = ElementsListMixin.openapi_overrides.copy() - openapi_overrides.update({ - 'operationId': 'ListElements', - 'parameters': ElementsListMixin.openapi_overrides['parameters'] + [ - { - 'name': 'top_level', - 'in': 'query', - 'description': 'Only include folder elements without parent elements (top-level elements).', - 'required': False, - 'schema': {'type': 'boolean', 'default': False}, - } - ] - }) @property def is_top_level(self): @@ -499,27 +467,24 @@ class CorpusElements(ElementsListMixin, CorpusACLMixin, DestroyModelMixin, ListA return super().get_order_by() -class ElementParents(ElementsListMixin, DestroyModelMixin, ACLMixin, ListAPIView): +@extend_schema( + parameters=[ + OpenApiParameter( + 'recursive', + type=bool, + description='List recursively (parents, grandparents, etc.)', + required=False, + ), + ] +) +@extend_schema_view( + get=extend_schema(operation_id='ListElementParents'), + delete=extend_schema(operation_id='DestroyElementParents', description='Delete parent elements in bulk'), +) +class ElementParents(ElementsListBase): """ List all parents of an element """ - openapi_overrides = ElementsListMixin.openapi_overrides.copy() - openapi_overrides.update({ - 'operationId': 'ListElementParents', - 'description': 'List all parents of an element', - 'parameters': ElementsListMixin.openapi_overrides['parameters'] + [ - { - 'name': 'recursive', - 'in': 'query', - 'description': 'List recursively (parents, grandparents, etc.)', - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': False - } - }, - ] - }) @property def is_recursive(self): @@ -539,27 +504,24 @@ class ElementParents(ElementsListMixin, DestroyModelMixin, ACLMixin, ListAPIView return Element.objects.get_ascending(self.kwargs['pk'], recursive=self.is_recursive) -class ElementChildren(ElementsListMixin, DestroyModelMixin, ACLMixin, ListAPIView): +@extend_schema( + parameters=[ + OpenApiParameter( + 'recursive', + type=bool, + description='List recursively (children, grandchildren, etc.)', + required=False, + ), + ] +) +@extend_schema_view( + get=extend_schema(operation_id='ListElementChildren'), + delete=extend_schema(operation_id='DestroyElementChildren', description='Delete child elements in bulk'), +) +class ElementChildren(ElementsListBase): """ List all children of an element """ - openapi_overrides = ElementsListMixin.openapi_overrides.copy() - openapi_overrides.update({ - 'operationId': 'ListElementChildren', - 'description': 'List all children of an element', - 'parameters': ElementsListMixin.openapi_overrides['parameters'] + [ - { - 'name': 'recursive', - 'in': 'query', - 'description': 'List recursively (children, grandchildren, etc.)', - 'required': False, - 'schema': { - 'type': 'boolean', - 'default': False - } - }, - ] - }) def get_filters(self): filters = super().get_filters(corpus=self.element.corpus) @@ -659,27 +621,27 @@ class ElementRetrieve(ACLMixin, RetrieveUpdateDestroyAPIView): return Response(status=status.HTTP_204_NO_CONTENT) +@extend_schema_view( + get=extend_schema( + operation_id='ListElementNeighbors', + tags=['elements'], + parameters=[ + OpenApiParameter( + 'n', + type={'type': 'integer', 'minimum': 1, 'maximum': 10}, + description='Number of neighbors to retrieve around the element', + required=False, + ) + ], + ) +) class ElementNeighbors(ListAPIView): """ List neighboring elements """ serializer_class = ElementNeighborsSerializer - openapi_overrides = { - 'tags': ['elements'], - 'parameters': [ - { - 'name': 'n', - 'in': 'query', - 'description': 'Number of neighbors to retrieve around the element', - 'required': False, - 'schema': { - 'type': 'integer', - 'minimum': 1, - 'maximum': 10, - }, - }, - ] - } + # For OpenAPI type discovery + queryset = Element.objects.none() def get_queryset(self): n = self.request.query_params.get('n', 1) @@ -698,19 +660,12 @@ class ElementNeighbors(ListAPIView): return Element.objects.get_neighbors(element, n) -class NoRequestBodySchema(AutoSchema): - def _get_request_body(self, path, method): - # Remove request body from OpenAPI schema - return None - - class ElementParent(CreateAPIView, DestroyAPIView): """ Manage element relation with one of its parents """ serializer_class = ElementParentSerializer permission_classes = (IsVerified, ) - schema = NoRequestBodySchema() openapi_overrides = { 'tags': ['elements'], } @@ -735,31 +690,28 @@ class ElementParent(CreateAPIView, DestroyAPIView): return Response(status=status.HTTP_204_NO_CONTENT) +@extend_schema(tags=['elements']) +@extend_schema_view( + get=extend_schema( + operation_id='ListSelection', + description='List all selected elements', + parameters=[ + OpenApiParameter( + 'with_best_classes', + description='Returns best classifications for each element. ' + 'If not set, elements best_classes field will always be null', + type=bool, + required=False, + ), + ], + ) +) class ManageSelection(SelectionMixin, ListAPIView): """ Manage all selected elements """ serializer_class = ElementListSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'operationId': 'ManageSelection', - 'security': [], - 'tags': ['elements'], - 'parameters': [ - { - 'name': 'with_best_classes', - 'in': 'query', - 'description': ( - 'Returns best classifications for each element. ' - 'If not set, elements best_classes field will always be null' - ), - 'required': False, - 'schema': { - 'type': 'boolean', - } - } - ] - } def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) @@ -884,6 +836,26 @@ class TranscriptionsPagination(PageNumberPagination): page_size = 100 +@extend_schema_view( + get=extend_schema( + operation_id='ListTranscriptions', + tags=['transcriptions'], + parameters=[ + OpenApiParameter( + 'recursive', + type=bool, + description='Recursively list transcriptions on sub-elements', + required=False, + ), + OpenApiParameter( + 'worker_version', + type=UUID, + description='Filter transcriptions by worker version', + required=False, + ) + ] + ) +) class ElementTranscriptions(ListAPIView): """ List all transcriptions for an element, optionally filtered by type or worker version id. @@ -892,34 +864,7 @@ class ElementTranscriptions(ListAPIView): """ serializer_class = ElementTranscriptionSerializer pagination_class = TranscriptionsPagination - openapi_overrides = { - 'operationId': 'ListTranscriptions', - 'security': [], - 'tags': ['transcriptions'], - 'parameters': [ - { - 'name': 'recursive', - 'in': 'query', - 'required': False, - 'description': 'Recursively list transcriptions on sub-elements', - 'schema': {'type': 'boolean'} - }, - { - 'name': 'worker_version', - 'in': 'query', - 'required': False, - 'description': 'Filter transcriptions by worker version', - 'schema': {'type': 'string', 'format': 'uuid'} - }, - { - 'name': 'element_type', - 'in': 'query', - 'required': False, - 'description': 'Filter transcriptions by the type of their element', - 'schema': {'type': 'string'} - }, - ] - } + queryset = Transcription.objects.none() @property def is_recursive(self): @@ -972,34 +917,42 @@ class ElementTranscriptions(ListAPIView): return queryset +@extend_schema_view( + post=extend_schema( + operation_id='CreateElement', + tags=['elements'], + parameters=[ + OpenApiParameter( + 'slim_output', + type=bool, + description="When enabled, the endpoint will only return the new Element's ID." + "This is especially useful for an automated tool, as it speeds up the element creation.", + required=False, + ) + ] + ) +) class ElementsCreate(CreateAPIView): """ Create a new element """ permission_classes = (IsVerified, ) serializer_class = ElementCreateSerializer - openapi_overrides = { - 'operationId': 'CreateElement', - 'tags': ['elements'], - 'parameters': [ - { - 'name': 'slim_output', - 'in': 'query', - 'required': False, - 'description': 'When enabled, the endpoint will only return the new Element\'s ID. This is especially useful for an automated tool, as it speeds up the element creation.', - 'schema': { - 'type': 'boolean', - } - }, - ] - } +@extend_schema_view( + get=extend_schema( + operation_id='ListElementMetaData', + description='List all metadata linked to an element.', + tags=['elements'], + ), + post=extend_schema( + operation_id='CreateMetaData', + tags=['elements'], + ) +) class ElementMetadata(ListCreateAPIView): """ - List all metadata linked to an element. - - Create a metadata on an existing element. The `date` type allows dates to be parsed and indexed for search APIs. The supported date formats are as follows: @@ -1016,9 +969,6 @@ class ElementMetadata(ListCreateAPIView): permission_classes = (IsVerified, ) pagination_class = None serializer_class = MetaDataUpdateSerializer - openapi_overrides = { - 'tags': ['elements'] - } def get_queryset(self): if self.request and self.request.method == 'GET': @@ -1031,9 +981,12 @@ class ElementMetadata(ListCreateAPIView): def get_serializer_context(self): context = super().get_serializer_context() - if self.request and self.request.method == 'GET': + # Ignore this step when generating the schema with OpenAPI + if not self.request: + return context + if self.request.method == 'GET': context['list'] = True - elif self.request and self.request.method != 'GET': # Ignore this step when generating the schema with OpenAPI + else: context['element'] = self.get_object() return context @@ -1068,19 +1021,17 @@ class AllowedMetaDataPagination(PageNumberPagination): class CorpusAllowedMetaData(CorpusACLMixin, ListAPIView): """ - List allowed metadata (type, name) couples in a corpus + List (type, name) couples allowed for corpus metadata """ serializer_class = CorpusAllowedMetaDataSerializer pagination_class = AllowedMetaDataPagination openapi_overrides = { 'operationId': 'ListCorpusAllowedMetaDatas', - 'description': 'List (type, name) couples allowed for corpus metadata', 'tags': ['corpora'] } + queryset = AllowedMetaData.objects.none() def get_queryset(self): - if not self.request: - return AllowedMetaData.objects.none() return AllowedMetaData.objects.filter(corpus=self.get_corpus(self.kwargs['pk'])) @@ -1110,30 +1061,24 @@ class ElementTypeUpdate(UpdateAPIView): return ElementType.objects.filter(corpus__in=Corpus.objects.readable(self.request.user)) +@extend_schema_view( + post=extend_schema( + operation_id='CreateElements', + tags=['elements'], + responses=inline_serializer( + 'ElementBulkCreateResponse', + fields={'id': serializers.UUIDField()}, + many=True, + ), + ) +) class ElementBulkCreate(CreateAPIView): """ Create multiple child elements at once on a single parent """ serializer_class = ElementBulkSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'operationId': 'CreateElements', - 'tags': ['elements'], - 'responses': { - '201': { - 'description': 'Created', - 'content': { - 'application/json': { - 'examples': [{ - 'value': [{ - 'id': 'string' - }] - }] - } - } - } - } - } + pagination_class = None def get_object(self): if not hasattr(self, 'element'): @@ -1248,7 +1193,7 @@ class ElementBulkCreate(CreateAPIView): return [{'id': element_data['element'].id} for element_data in elements] -class CorpusDeleteSelection(CorpusACLMixin, SelectionMixin, DestroyAPIView): +class CorpusSelectionDestroy(CorpusACLMixin, SelectionMixin, DestroyAPIView): """ Delete all selected elements on a corpus """ diff --git a/arkindex/documents/api/entities.py b/arkindex/documents/api/entities.py index 2adf387470971e15eb6be0ff7c635d48d38333e9..e307d1b73f99a2c478c6b5af09ee2fb23da0eb14 100644 --- a/arkindex/documents/api/entities.py +++ b/arkindex/documents/api/entities.py @@ -4,6 +4,7 @@ from uuid import UUID from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import Q +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from elasticsearch.exceptions import NotFoundError from rest_framework import permissions, serializers, status from rest_framework.exceptions import NotFound, PermissionDenied @@ -54,6 +55,7 @@ class CorpusRoles(CorpusACLMixin, ListCreateAPIView): openapi_overrides = { 'tags': ['entities'] } + queryset = EntityRole.objects.none() def get_queryset(self): return EntityRole.objects \ @@ -126,9 +128,10 @@ class EntityElements(ListAPIView): serializer_class = ElementSlimSerializer openapi_overrides = { 'operationId': 'ListEntityElements', - 'security': [], 'tags': ['entities'], } + # For OpenAPI type discovery: an entity's ID is in the path + queryset = Entity.objects.none() def get_queryset(self): pk = self.kwargs['pk'] @@ -206,6 +209,8 @@ class TranscriptionEntityCreate(CreateAPIView): 'operationId': 'CreateTranscriptionEntity', 'tags': ['entities'], } + # For OpenAPI type discovery: a transcription's ID is in the path + queryset = Transcription.objects.none() def get_serializer_context(self): context = super().get_serializer_context() @@ -234,28 +239,27 @@ class TranscriptionEntityCreate(CreateAPIView): super().perform_create(serializer) +@extend_schema_view( + get=extend_schema( + operation_id='ListTranscriptionEntities', + tags=['entities'], + parameters=[ + OpenApiParameter( + 'worker_version', + type=UUID, + description='Only include entities created by a specific worker version', + required=False, + ) + ] + ) +) class TranscriptionEntities(ListAPIView): """ List existing entities linked to a specific transcription """ serializer_class = TranscriptionEntityDetailsSerializer - openapi_overrides = { - 'operationId': 'ListTranscriptionEntities', - 'tags': ['entities'], - 'security': [], - 'parameters': [ - { - 'name': 'worker_version', - 'in': 'query', - 'description': 'Only include entities created by a specific worker version', - 'required': False, - 'schema': { - 'type': 'string', - 'format': 'uuid', - } - }, - ] - } + # For OpenAPI type discovery: a transcription's ID is in the path + queryset = Transcription.objects.none() def get_queryset(self): filters = { @@ -279,28 +283,25 @@ class TranscriptionEntities(ListAPIView): .prefetch_related('entity') +@extend_schema_view( + get=extend_schema( + operation_id='ListElementEntities', + tags=['entities'], + parameters=[ + OpenApiParameter( + 'worker_version', + type=UUID, + description='Only include entities created by a specific worker version', + required=False, + ) + ] + ) +) class ElementEntities(RetrieveAPIView): """ List all entities linked to an element's transcriptions and metadata """ serializer_class = ElementEntitiesSerializer - openapi_overrides = { - 'operationId': 'ListElementEntities', - 'tags': ['entities'], - 'security': [], - 'parameters': [ - { - 'name': 'worker_version', - 'in': 'query', - 'description': 'Only include entities created by a specific worker version', - 'required': False, - 'schema': { - 'type': 'string', - 'format': 'uuid', - } - }, - ] - } def get_queryset(self): corpora = Corpus.objects.readable(self.request.user) @@ -316,6 +317,7 @@ class ElementLinks(ListAPIView): 'operationId': 'ListElementLinks', 'tags': ['entities'], } + queryset = EntityLink.objects.none() def get_queryset(self): try: diff --git a/arkindex/documents/api/iiif.py b/arkindex/documents/api/iiif.py index 82ef3db78c396f432247c076188c6938987be250..2f6069587c15aa38b4b700edaaca19e74926e7dd 100644 --- a/arkindex/documents/api/iiif.py +++ b/arkindex/documents/api/iiif.py @@ -1,5 +1,6 @@ from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework.exceptions import PermissionDenied from rest_framework.generics import RetrieveAPIView @@ -17,16 +18,9 @@ from arkindex.project.mixins import SearchAPIMixin class FolderManifest(RetrieveAPIView): """ - Get a IIIF manifest for a specific volume + Retrieve an IIIF manifest for a folder element. """ - serializer_class = FolderManifestSerializer - openapi_overrides = { - 'operationId': 'RetrieveFolderManifest', - 'description': 'Retrieve an IIIF manifest for a folder element.', - 'security': [], - 'tags': ['iiif'], - } def get_queryset(self): return Element.objects.filter( @@ -35,22 +29,21 @@ class FolderManifest(RetrieveAPIView): ) @method_decorator(cache_page(3600)) + @extend_schema( + operation_id='RetrieveFolderManifest', + responses={200: {'type': 'object'}}, + tags=['iiif'], + ) def get(self, *args, **kwargs): return super().get(*args, **kwargs) class ElementAnnotationList(RetrieveAPIView): """ - Get a IIIF annotation list for transcriptions of a specific page + Retrieve an IIIF annotation list for transcriptions on elements with zones """ serializer_class = ElementAnnotationListSerializer - openapi_overrides = { - 'operationId': 'RetrieveElementAnnotationList', - 'description': 'Retrive an IIIF annotation list for transcriptions on elements with zones', - 'security': [], - 'tags': ['iiif'], - } def get_queryset(self): return Element.objects.filter( @@ -59,23 +52,31 @@ class ElementAnnotationList(RetrieveAPIView): ) @method_decorator(cache_page(3600)) + @extend_schema( + operation_id='RetrieveElementAnnotationList', + responses={200: {'type': 'object'}}, + tags=['iiif'], + ) def get(self, *args, **kwargs): return super().get(*args, **kwargs) +@extend_schema_view( + get=extend_schema( + operation_id='SearchTranscriptionsAnnotationList', + responses={200: {'type': 'object'}}, + tags=['iiif'], + ) +) class TranscriptionSearchAnnotationList(SearchAPIMixin, RetrieveAPIView): """ - Search for transcriptions inside an element and get a IIIF annotation list + Retrieve an IIIF Search API annotation list for transcriptions on a folder element """ serializer_class = TranscriptionSearchAnnotationListSerializer query_serializer_class = IIIFSearchQuerySerializer - openapi_overrides = { - 'operationId': 'SearchTranscriptionsAnnotationList', - 'description': 'Retrieve an IIIF Search API annotation list for transcriptions on a folder element', - 'security': [], - 'tags': ['iiif'], - } + # For OpenAPI type discovery: an element's ID is in the path + queryset = Element.objects.none() elt = None def get_element(self): diff --git a/arkindex/documents/api/ml.py b/arkindex/documents/api/ml.py index 4be00970f30dcb3f570663dfb7ba006376651f93..334c8d8bada5056778845d4798088b1828c343b0 100644 --- a/arkindex/documents/api/ml.py +++ b/arkindex/documents/api/ml.py @@ -3,6 +3,7 @@ import uuid from django.db import transaction from django.db.models import Count, Q +from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_view from rest_framework import permissions, status from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.generics import ( @@ -48,13 +49,11 @@ logger = logging.getLogger(__name__) class TranscriptionCreate(CreateAPIView): """ - Create a single transcription on an element - The transcription zone is defined by the element it is attached + Create a single transcription attached to an element """ serializer_class = TranscriptionCreateSerializer permission_classes = (IsVerified, ) openapi_overrides = { - 'description': 'Create a single manual transcription attached to an element', 'operationId': 'CreateTranscription', 'tags': ['transcriptions'], } @@ -102,6 +101,19 @@ class TranscriptionCreate(CreateAPIView): ) +@extend_schema(tags=['transcriptions']) +@extend_schema_view( + get=extend_schema(description='Retrieve a transcription.'), + patch=extend_schema( + description="Update a transcription's text. " + "For non-manual transcriptions, this requires an admin role on the corpus." + ), + put=extend_schema( + description="Update a transcription's text. " + "For non-manual transcriptions, this requires an admin role on the corpus." + ), + delete=extend_schema(description='Delete a transcription.'), +) class TranscriptionEdit(ACLMixin, RetrieveUpdateDestroyAPIView): """ Retrieve or edit a transcription @@ -110,9 +122,6 @@ class TranscriptionEdit(ACLMixin, RetrieveUpdateDestroyAPIView): """ serializer_class = TranscriptionSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'tags': ['transcriptions'], - } def get_queryset(self): if not self.request: @@ -135,45 +144,27 @@ class TranscriptionEdit(ACLMixin, RetrieveUpdateDestroyAPIView): raise PermissionDenied(detail=detail) +@extend_schema_view( + post=extend_schema( + operation_id='CreateElementTranscriptions', + tags=['transcriptions'], + # Return different serializers depending on return_elements + responses=PolymorphicProxySerializer( + component_name='ElementTranscriptionsResponse', + resource_type_field_name='return_elements', + serializers={ + True: AnnotatedElementSerializer, + False: ElementTranscriptionsBulkSerializer, + }, + ) + ) +) class ElementTranscriptionsBulk(CreateAPIView): """ - Insert multiple sub elements with their transcriptions on a common parent + Create multiple sub elements with their transcriptions on a common parent in one transaction """ serializer_class = ElementTranscriptionsBulkSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'description': 'Create multiple sub elements with their transcriptions on a common parent in one transaction', - 'operationId': 'CreateElementTranscriptions', - 'tags': ['transcriptions'], - 'responses': { - '201': { - 'description': 'Created', - 'content': { - 'application/json': { - 'examples': [{ - 'summary': 'return_elements = false', - 'value': { - 'element_type': 'string', - 'worker_version': 'string', - 'transcriptions': { - 'polygon': [[]], - 'text': 'string', - 'score': 0 - }, - 'return_elements': False - } - }, { - 'summary': 'return_elements = true', - 'value': [{ - 'id': 'string', - 'created': True - }] - }] - } - } - } - } - } def get_object(self): if not hasattr(self, 'element'): @@ -327,26 +318,28 @@ class TranscriptionBulk(CreateAPIView): serializer_class = TranscriptionBulkSerializer +@extend_schema(tags=['classifications']) +@extend_schema_view( + get=extend_schema( + operation_id='ListCorpusMLClasses', + ), + post=extend_schema( + operation_id='CreateMLClass', + description='Create an ML class in a corpus', + ) +) class CorpusMLClassList(CorpusACLMixin, ListCreateAPIView): """ - List available classes in a corpus with their distribution over elements + List available classes in a corpus with their distribution over elements (best classes count) """ serializer_class = CountMLClassSerializer - openapi_overrides = { - 'operationId': 'ListCorpusMLClasses', - 'description': ( - 'List all available classes in a corpus with their' - ' distribution over elements (best classes count)' - ), - 'tags': ['classifications'], - } + # For OpenAPI type discovery: a corpus ID is in the path + queryset = Corpus.objects.none() filter_backends = [SafeSearchFilter] search_fields = ['name'] permission_classes = (IsVerifiedOrReadOnly, ) def get_queryset(self): - if not self.request: - return MLClass.objects.none() best_classification_filter = Q( # Keep non rejected best or validated classifications ( @@ -373,11 +366,10 @@ class CorpusMLClassList(CorpusACLMixin, ListCreateAPIView): class MLClassList(DeprecatedMixin, ListAPIView): """ - List all available classes + List available machine learning classes in all corpora. """ openapi_overrides = { 'operationId': 'ListMLClasses', - 'description': 'List available machine learning classes in all corpora.', 'tags': ['classifications'], } deprecation_message = 'ListMLClasses is deprecated. Please use `ListCorpusMLClasses` instead.' @@ -421,12 +413,11 @@ class ClassificationBulk(CreateAPIView): class ManageClassificationsSelection(SelectionMixin, CorpusACLMixin, CreateAPIView): """ - Create multiple classifications at once for a list of selected elements for a specific corpus + Manage classifications for a list of selected elements for a specific corpus. """ serializer_class = ClassificationsSelectionSerializer permission_classes = (IsVerified, ) openapi_overrides = { - 'description': 'Manage classifications for a list of selected elements for a specific corpus.', 'tags': ['classifications'] } @@ -495,24 +486,23 @@ class ClassificationModerationActionsMixin(GenericAPIView): class ClassificationValidate(ClassificationModerationActionsMixin): """ - Validate a classification + Validate an existing classification. """ moderation_action = ClassificationState.Validated openapi_overrides = { 'operationId': 'ValidateClassification', - 'description': 'Validate an existing classification.', 'tags': ['classifications'], } class ClassificationReject(ClassificationModerationActionsMixin): """ - Reject a classification + Reject an existing classification. + Manual classifications will be deleted. """ moderation_action = ClassificationState.Rejected openapi_overrides = { 'operationId': 'RejectClassification', - 'description': 'Reject an existing classification. Manual classifications will be deleted.', 'tags': ['classifications'], } diff --git a/arkindex/documents/api/search.py b/arkindex/documents/api/search.py index eeda0732222250e09a7c28d8392e3d31694576f1..ff585ef4411b7987c0b1739c211b7c54adb1ea71 100644 --- a/arkindex/documents/api/search.py +++ b/arkindex/documents/api/search.py @@ -1,4 +1,5 @@ from django.conf import settings +from drf_spectacular.utils import extend_schema, extend_schema_view from elasticsearch_dsl.function import FieldValueFactor from elasticsearch_dsl.query import FunctionScore, Nested, Q from rest_framework.generics import ListAPIView @@ -8,25 +9,28 @@ from arkindex.documents.serializers.search import ( ElementSearchResultSerializer, EntitySearchQuerySerializer, EntitySearchResultSerializer, + SearchQuerySerializer, ) from arkindex.project.elastic import ESElement, ESEntity from arkindex.project.mixins import SearchAPIMixin -class SearchAPIView(SearchAPIMixin, ListAPIView): +@extend_schema_view( + get=extend_schema( + operation_id='SearchElements', + tags=['search'], + parameters=[SearchQuerySerializer], + ) +) +class ElementSearch(SearchAPIMixin, ListAPIView): """ - A base class for ES search views + Get a list of elements with their parents, the total number of transcriptions + in each element, and a few (not all) of their transcriptions, with their source, + type, zone and image, for a given query. """ - - -class ElementSearch(SearchAPIView): serializer_class = ElementSearchResultSerializer openapi_overrides = { 'operationId': 'SearchElements', - 'security': [], - 'description': 'Get a list of elements with their parents, the total number of transcriptions ' - 'in each element, and a few (not all) of their transcriptions, with their worker version, ' - 'type, zone and image, for a given query.', 'tags': ['search'], } @@ -81,18 +85,16 @@ class ElementSearch(SearchAPIView): return search_elements_post(*args) -class EntitySearch(SearchAPIView): - """ - Search and list entities - """ +@extend_schema_view( + get=extend_schema( + operation_id='SearchEntities', + tags=['search'], + parameters=[EntitySearchQuerySerializer], + ) +) +class EntitySearch(SearchAPIMixin, ListAPIView): serializer_class = EntitySearchResultSerializer query_serializer_class = EntitySearchQuerySerializer - openapi_overrides = { - 'description': 'Get a list of entities', - 'operationId': 'SearchEntities', - 'security': [], - 'tags': ['search'], - } def get_search(self, query=None, type=None, corpora_ids=None): assert corpora_ids, 'Must filter by corpora' diff --git a/arkindex/documents/apps.py b/arkindex/documents/apps.py index bcac7a32f5ebb467607b5047e8eb2b3c5ff6e9c5..a73b0755e2fbdf5a97dea47844be4954f1ed55d9 100644 --- a/arkindex/documents/apps.py +++ b/arkindex/documents/apps.py @@ -6,3 +6,4 @@ class DocumentsConfig(AppConfig): def ready(self): from arkindex.project import checks # noqa: F401 + from arkindex.project.openapi import extensions # noqa: F401 diff --git a/arkindex/documents/serializers/elements.py b/arkindex/documents/serializers/elements.py index 8e3c09fe3b075b277d4c8880aaaa3b11f9474c4e..225380f927011c19b73235a3a7112baef60fd6e9 100644 --- a/arkindex/documents/serializers/elements.py +++ b/arkindex/documents/serializers/elements.py @@ -4,6 +4,7 @@ from uuid import UUID from django.contrib.gis.geos import LinearRing from django.db import transaction from django.utils.functional import cached_property +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -93,6 +94,7 @@ class CorpusSerializer(serializers.ModelSerializer): 'authorized_users', ) + @extend_schema_field(serializers.ListField(serializers.CharField())) def get_rights(self, corpus): if getattr(corpus, 'access_level', None): @@ -108,7 +110,7 @@ class CorpusSerializer(serializers.ModelSerializer): rights.append('admin') return rights - def get_authorized_users(self, corpus): + def get_authorized_users(self, corpus) -> int: count = getattr(corpus, 'authorized_users', None) if count is None: count = corpus.memberships.count() @@ -154,6 +156,7 @@ class ElementTinySerializer(serializers.ModelSerializer): return self.fields['type'].queryset = ElementType.objects.filter(corpus=self.context['element'].corpus) + @extend_schema_field(serializers.CharField(allow_null=True)) def get_thumbnail_url(self, element): if element.type.folder: return element.thumbnail.s3_url @@ -182,6 +185,7 @@ class ElementSlimSerializer(ElementTinySerializer): """ thumbnail_put_url = serializers.SerializerMethodField(read_only=True) + @extend_schema_field(serializers.CharField(allow_null=True)) def get_thumbnail_put_url(self, element): user = self.context['request'].user if user.is_authenticated and (user.is_admin or user.is_internal) and element.type.folder: @@ -207,6 +211,7 @@ class ElementListSerializer(ElementTinySerializer): if 'with_corpus' in self.context and not self.context['with_corpus']: self.fields.pop('corpus') + @extend_schema_field(serializers.CharField(allow_null=True)) def get_thumbnail_url(self, element): # Do not return full element zone when with_zone query parameter is false, instead just return a thumbnail if 'with_zone' in self.context and not self.context['with_zone'] and not element.type.folder and element.zone: @@ -346,6 +351,7 @@ class ElementSerializer(ElementSlimSerializer): instance = super().update(instance, validated_data) return instance + @extend_schema_field(MetaDataLightSerializer(many=True)) def get_metadata(self, obj): if hasattr(self.instance, 'metadata_count'): return self.instance.metadata_count @@ -549,6 +555,7 @@ class ElementEntitiesSerializer(ElementLightSerializer): return TranscriptionEntityDetailsSerializer(transcription_entities, many=True).data + @extend_schema_field(MetaDataSerializer(many=True)) def get_metadata(self, obj): if self.worker_version_id: # entity__isnull is not needed here as this will already do an INNER JOIN and exclude nulls diff --git a/arkindex/documents/serializers/iiif/annotations.py b/arkindex/documents/serializers/iiif/annotations.py index a32a9129e91dd3b9c336e80bc2eb405486f70043..0fd490db0c5be901bedcbbe35707560f65144a6b 100644 --- a/arkindex/documents/serializers/iiif/annotations.py +++ b/arkindex/documents/serializers/iiif/annotations.py @@ -84,7 +84,7 @@ class TranscriptionSearchAnnotationSerializer(TranscriptionAnnotationSerializer) return f'{url}#xywh={x},{y},{w},{h}' -class AnnotationListSerializer(serializers.BaseSerializer): +class AnnotationListSerializer(serializers.Serializer): """ Serialize a list of serialized annotations into a IIIF annotation list """ diff --git a/arkindex/documents/serializers/iiif/manifests.py b/arkindex/documents/serializers/iiif/manifests.py index 094185915b70f0d2a2c669626e76c2e8136e7a08..e1bb717997cf5948ae6ea1ae13a294fec6f011be 100644 --- a/arkindex/documents/serializers/iiif/manifests.py +++ b/arkindex/documents/serializers/iiif/manifests.py @@ -112,7 +112,7 @@ class PageCanvasManifestSerializer(ElementCanvasManifestSerializer): ] -class FolderManifestSerializer(serializers.BaseSerializer): +class FolderManifestSerializer(serializers.Serializer): """ Serialize a folder into a IIIF manifest """ diff --git a/arkindex/documents/tests/test_destroy_elements.py b/arkindex/documents/tests/test_destroy_elements.py index 2abe93743a28e6be8b8a003df7fa8f2828f92a47..420138591c8c24e5977d64b5fb994e0955a690a6 100644 --- a/arkindex/documents/tests/test_destroy_elements.py +++ b/arkindex/documents/tests/test_destroy_elements.py @@ -246,26 +246,6 @@ class TestDestroyElements(FixtureAPITestCase): 'description': 'Element deletion', }) - @patch('arkindex.project.triggers.tasks.element_trash.delay') - def test_destroy_corpus_elements_rejected_filters(self, delay_mock): - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.delete( - reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}), - QUERY_STRING='with_best_classes=True&with_has_children=True&best_class=True', - HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT', - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), { - 'If-Modified-Since': ['This parameter is not allowed in DELETE requests.'], - 'best_class': ['This parameter is not allowed in DELETE requests.'], - 'with_best_classes': ['This parameter is not allowed in DELETE requests.'], - 'with_has_children': ['This parameter is not allowed in DELETE requests.'], - }) - - self.assertFalse(delay_mock.called) - @patch('arkindex.project.triggers.tasks.element_trash.delay') def test_destroy_element_children_requires_login(self, delay_mock): with self.assertNumQueries(0): @@ -341,26 +321,6 @@ class TestDestroyElements(FixtureAPITestCase): 'description': 'Element deletion', }) - @patch('arkindex.project.triggers.tasks.element_trash.delay') - def test_destroy_element_children_rejected_filters(self, delay_mock): - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.delete( - reverse('api:elements-children', kwargs={'pk': self.vol.id}), - QUERY_STRING='with_best_classes=True&with_has_children=True&best_class=True', - HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT', - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), { - 'If-Modified-Since': ['This parameter is not allowed in DELETE requests.'], - 'best_class': ['This parameter is not allowed in DELETE requests.'], - 'with_best_classes': ['This parameter is not allowed in DELETE requests.'], - 'with_has_children': ['This parameter is not allowed in DELETE requests.'], - }) - - self.assertFalse(delay_mock.called) - @patch('arkindex.project.triggers.tasks.element_trash.delay') def test_destroy_element_parents_requires_login(self, delay_mock): with self.assertNumQueries(0): @@ -435,23 +395,3 @@ class TestDestroyElements(FixtureAPITestCase): 'user_id': self.user.id, 'description': 'Element deletion', }) - - @patch('arkindex.project.triggers.tasks.element_trash.delay') - def test_destroy_element_parents_rejected_filters(self, delay_mock): - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.delete( - reverse('api:elements-parents', kwargs={'pk': self.surface.id}), - QUERY_STRING='with_best_classes=True&with_has_children=True&best_class=True', - HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT', - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), { - 'If-Modified-Since': ['This parameter is not allowed in DELETE requests.'], - 'best_class': ['This parameter is not allowed in DELETE requests.'], - 'with_best_classes': ['This parameter is not allowed in DELETE requests.'], - 'with_has_children': ['This parameter is not allowed in DELETE requests.'], - }) - - self.assertFalse(delay_mock.called) diff --git a/arkindex/images/api.py b/arkindex/images/api.py index 38ccf38a65154cf730b8b02afe21e58d79b74224..1ad4c9e1dc4d77526275b0957df579d5a16e16ca 100644 --- a/arkindex/images/api.py +++ b/arkindex/images/api.py @@ -1,4 +1,6 @@ from django.db.models import Q +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import status from rest_framework.generics import CreateAPIView, ListAPIView, RetrieveUpdateAPIView from rest_framework.response import Response @@ -83,15 +85,19 @@ class IIIFInformationCreate(CreateAPIView): Image Information specification: https://iiif.io/api/image/2.1/#image-information """ - openapi_overrides = { - 'operationId': 'CreateIIIFInformation', - 'tags': ['images'], - } serializer_class = ImageInformationSerializer permission_classes = (IsVerified, ) scopes = (Scope.CreateIIIFImage, ) - def create(self, request, *args, **kwargs): + @extend_schema( + operation_id='CreateIIIFInformation', + # ImageInformationSerializer does not have any defined fields, so it is ignored in OpenAPI + # This adds a schema that accepts any JSON object and causes a `requestBody` to be still present + request=OpenApiTypes.OBJECT, + responses={200: IIIFImageSerializer}, + tags=['images'], + ) + def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) @@ -113,35 +119,32 @@ class ImageRetrieve(RetrieveUpdateAPIView): queryset = Image.objects.using('default').all() +@extend_schema_view( + get=extend_schema( + operation_id='ListImageElements', + tags=['images'], + parameters=[ + OpenApiParameter( + 'type', + description='Filter elements by type', + required=False, + ), + OpenApiParameter( + 'folder', + description='Restrict to or exclude elements with folder types', + type=bool, + required=False, + ), + ] + ) +) class ImageElements(ListAPIView): """ List elements on an image """ - + # For OpenAPI type discovery: an image's ID is in the path + queryset = Image.objects.none() serializer_class = ElementSlimSerializer - openapi_overrides = { - 'operationId': 'ListImageElements', - 'security': [], - 'tags': ['images'], - 'parameters': [ - { - 'name': 'type', - 'in': 'query', - 'description': 'Filter by element type', - 'required': False, - 'schema': {'type': 'string'}, - }, - { - 'name': 'folder', - 'in': 'query', - 'description': 'Restrict to or exclude elements with folder types', - 'required': False, - 'schema': { - 'type': 'boolean', - } - }, - ] - } def get_queryset(self): filters = { diff --git a/arkindex/images/models.py b/arkindex/images/models.py index d6e3ea6626ca6854bfbe920fa0e42f875d0a45fb..3917bc8867fc12b9984c15a9fec5e324b61ed26c 100644 --- a/arkindex/images/models.py +++ b/arkindex/images/models.py @@ -210,7 +210,7 @@ class Image(S3FileMixin, IndexableModel): ] @property - def url(self): + def url(self) -> str: return self.server.build_url(self.path) def get_thumbnail_url(self, max_width=200, max_height=None): @@ -391,7 +391,7 @@ class Zone(IndexableModel): return 'Zone {}'.format(self.polygon) @property - def url(self): + def url(self) -> str: ''' Build the IIIF crop url ''' diff --git a/arkindex/images/serializers.py b/arkindex/images/serializers.py index 326e86e6e305dc8d11a81dbdbcf2a5f45171d1fe..7e1a14a3243f57679ba29925ab7cf0625d388bcc 100644 --- a/arkindex/images/serializers.py +++ b/arkindex/images/serializers.py @@ -1,6 +1,7 @@ import re import uuid +from drf_spectacular.utils import extend_schema_field from requests.exceptions import RequestException from rest_framework import serializers from rest_framework.exceptions import APIException, ValidationError @@ -44,6 +45,7 @@ class ImageSerializer(serializers.ModelSerializer): ) read_only_fields = ('id', 'path', 'width', 'height', 'url', 's3_url', 'server') + @extend_schema_field(serializers.CharField(allow_null=True)) def get_s3_url(self, obj): if 'request' not in self.context: return @@ -231,6 +233,7 @@ class ImageUploadSerializer(ImageSerializer): # Set a fixed default for the image ID - allows the ID to be used as the image path self.fields['id'].default = uuid.uuid4() + @extend_schema_field(serializers.CharField(allow_null=True)) def get_s3_put_url(self, obj): if obj.status == S3FileStatus.Checked or not obj.server.is_local: # No PUT for existing images or external servers diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py index 0512efb096d93842ac90f13ed2079faeb51f448d..21977a145b44a4b5ee27c47387ec7cf416f44335 100644 --- a/arkindex/project/api_v1.py +++ b/arkindex/project/api_v1.py @@ -1,8 +1,5 @@ -from django.conf import settings from django.urls import path -from django.views.decorators.cache import cache_page from django.views.generic.base import RedirectView -from rest_framework.schemas import get_schema_view from arkindex.dataimport.api import ( AvailableRepositoriesList, @@ -35,10 +32,10 @@ from arkindex.dataimport.api import ( from arkindex.documents.api.admin import ReindexStart from arkindex.documents.api.elements import ( CorpusAllowedMetaData, - CorpusDeleteSelection, CorpusElements, CorpusList, CorpusRetrieve, + CorpusSelectionDestroy, ElementBulkCreate, ElementChildren, ElementMetadata, @@ -80,7 +77,7 @@ from arkindex.documents.api.ml import ( ) from arkindex.documents.api.search import ElementSearch, EntitySearch from arkindex.images.api import IIIFInformationCreate, IIIFURLCreate, ImageCreate, ImageElements, ImageRetrieve -from arkindex.project.openapi import SchemaGenerator +from arkindex.project.openapi import OpenApiSchemaView from arkindex.users.api import ( CredentialsList, CredentialsRetrieve, @@ -105,9 +102,6 @@ from arkindex.users.api import ( UserTranskribus, ) -# Cache the OpenAPI schema view for a day -schema_view = cache_page(86400, key_prefix=settings.VERSION)(get_schema_view(generator_class=SchemaGenerator)) - api = [ # Elements @@ -141,7 +135,7 @@ api = [ path('corpus/<uuid:pk>/roles/', CorpusRoles.as_view(), name='corpus-roles'), path('corpus/<uuid:pk>/allowed-metadata/', CorpusAllowedMetaData.as_view(), name='corpus-allowed-metadata'), path('corpus/<uuid:pk>/versions/', CorpusWorkerVersionList.as_view(), name='corpus-versions'), - path('corpus/<uuid:pk>/selection/', CorpusDeleteSelection.as_view(), name='corpus-delete-selection'), + path('corpus/<uuid:pk>/selection/', CorpusSelectionDestroy.as_view(), name='corpus-delete-selection'), # Moderation path('ml-classes/', MLClassList.as_view(), name='mlclass-list'), @@ -263,5 +257,5 @@ api = [ path('reindex/', ReindexStart.as_view(), name='reindex-start'), # OpenAPI Schema - path('openapi/', schema_view, name='openapi-schema'), + path('openapi/', OpenApiSchemaView.as_view(), name='openapi-schema'), ] diff --git a/arkindex/project/aws.py b/arkindex/project/aws.py index 245d8fb18d4ba5da4f73025c41735f284373e53b..f7a5107defaebc3e40d56f510f1d84d3eb50d9ed 100644 --- a/arkindex/project/aws.py +++ b/arkindex/project/aws.py @@ -1,4 +1,5 @@ import logging +from functools import wraps from io import BytesIO import boto3.session @@ -33,6 +34,7 @@ def requires_s3_object(func): """ Decorator to handle not defined s3_object property """ + @wraps(func) def wrapper(self, *args, **kwargs): if not self.s3_object: logger.info( @@ -83,7 +85,7 @@ class S3FileMixin(object): @property @requires_s3_object - def s3_url(self): + def s3_url(self) -> str: # Handle different regions signatures if self.s3_region != settings.AWS_REGION: client = session.client( diff --git a/arkindex/project/mixins.py b/arkindex/project/mixins.py index 6e825ed5f702eac06d7b28eaf95704c501b842ae..6c799884bedb63011457a4385d8e53df612de55a 100644 --- a/arkindex/project/mixins.py +++ b/arkindex/project/mixins.py @@ -3,14 +3,14 @@ from django.core.exceptions import PermissionDenied from django.db.models import Q from django.shortcuts import get_object_or_404 from django.views.decorators.cache import cache_page +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework.exceptions import APIException, ValidationError -from rest_framework.serializers import Serializer +from rest_framework.serializers import CharField, Serializer from arkindex.dataimport.models import Repository, Worker from arkindex.documents.models import Corpus from arkindex.documents.serializers.search import SearchQuerySerializer from arkindex.project.elastic import ESQuerySet -from arkindex.project.openapi import AutoSchema, SearchAutoSchema from arkindex.project.pagination import CustomCursorPagination from arkindex.users.models import Role from arkindex.users.utils import check_level_param, filter_rights, get_max_level @@ -155,7 +155,6 @@ class CorpusACLMixin(ACLMixin): class SearchAPIMixin(CorpusACLMixin): query_serializer_class = SearchQuerySerializer - schema = SearchAutoSchema() search = None def get_search(self, **query): @@ -204,62 +203,40 @@ class SelectionMixin(object): return queryset +class DeprecatedExceptionSerializer(Serializer): + detail = CharField() + + class DeprecatedAPIException(APIException): status_code = 410 default_detail = 'This endpoint has been deprecated.' default_code = 'deprecated' -class DeprecatedAutoSchema(AutoSchema): - """ - OpenAPI schema for deprecated views. - """ - def _get_responses(self, path, method): - """ - Return nothing but the deprecation error response. - """ - deprecation_message = getattr(self.view, 'deprecation_message', None) or DeprecatedAPIException.default_detail - - schema = { - 'schema': { - 'type': 'object', - 'properties': { - 'detail': { - 'type': 'string', - 'const': deprecation_message - } - }, - 'example': { - 'detail': deprecation_message - } - } - } - - return { - str(DeprecatedAPIException.status_code): { - 'content': { - media_type: schema - for media_type in self.map_renderers(path, method) - }, - 'description': 'Deprecation message' - } - } - - def get_operation(self, path, method): - operation = super().get_operation(path, method) - operation['deprecated'] = True - return operation - - class DeprecatedMixin(object): - """ - Add this mixin to an APIView to make it deprecated. - """ - schema = DeprecatedAutoSchema() - # Add a default useless serializer because OpenAPI generation requires all endpoints to have one - serializer_class = Serializer + # Add this mixin to an APIView to make it deprecated. + + serializer_class = DeprecatedExceptionSerializer + pagination_class = None deprecation_message = None + @classmethod + def as_view(cls, *args, **kwargs): + # A custom schema class cannot be assigned on the mixin as it would be linked to the + # mixin itself, not a view class, so it would not override anything. + # We therefore have to apply deprecation overrides on the view itself, and + # we override as_view specifically as this is easier than writing a metaclass. + extend_schema_view(**{ + method: extend_schema( + deprecated=True, + responses={DeprecatedAPIException.status_code: DeprecatedExceptionSerializer}, + ) + # Apply extend_schema only on methods that exist on this endpoint + for method in cls.http_method_names + if hasattr(cls, method) + })(cls) + return super().as_view(*args, **kwargs) + def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) method = request.method.lower() diff --git a/arkindex/project/openapi/__init__.py b/arkindex/project/openapi/__init__.py index 594bca8ae0d2853f4a7071c49e3ad666a0a5a19d..0185bb10e1b4e7351b3f3e8872d2d445303fbe17 100644 --- a/arkindex/project/openapi/__init__.py +++ b/arkindex/project/openapi/__init__.py @@ -1,2 +1,9 @@ -from arkindex.project.openapi.generators import SchemaGenerator # noqa: F401 -from arkindex.project.openapi.schemas import AutoSchema, SearchAutoSchema # noqa: F401 +from arkindex.project.openapi.hooks import ( # noqa: F401 + apply_openapi_overrides, + apply_patch_yaml, + check_tags, + operation_id_pascal_case, + request_body_x_name, +) +from arkindex.project.openapi.schema import AutoSchema # noqa: F401 +from arkindex.project.openapi.views import OpenApiSchemaView # noqa: F401 diff --git a/arkindex/project/openapi/extensions.py b/arkindex/project/openapi/extensions.py new file mode 100644 index 0000000000000000000000000000000000000000..00778a87b341803356b7d939d2c6ffdc1767995c --- /dev/null +++ b/arkindex/project/openapi/extensions.py @@ -0,0 +1,149 @@ +""" +This module provides Spectacular extensions for authentication methods, serializers, fields and views +that Spectacular is unable to guess the correct schema for, and that we cannot easily override +with the usual `extend_schema`. + +Docs: https://drf-spectacular.readthedocs.io/en/latest/customization.html#step-5-extensions +""" + +from textwrap import dedent + +from drf_spectacular.contrib.rest_framework_simplejwt import SimpleJWTScheme +from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiViewExtension +from drf_spectacular.utils import OpenApiExample, extend_schema, extend_schema_field, extend_schema_view +from rest_framework import serializers + + +class AgentAuthenticationExtension(SimpleJWTScheme): + """ + Allows Spectacular to recognize the Ponos agent authentication as a JWT auth. + """ + target_class = 'ponos.authentication.AgentAuthentication' + name = 'agentAuth' + + +class PublicKeyEndpointExtension(OpenApiViewExtension): + """ + Overrides to let Spectacular know this outputs a plaintext PEM file. + """ + target_class = 'ponos.api.PublicKeyEndpoint' + + def view_replacement(self): + @extend_schema_view( + get=extend_schema( + operation_id='GetPublicKey', + responses={ + 200: {'type': 'string'} + }, + tags=['ponos'], + auth=[{'agentAuth': []}], + examples=[OpenApiExample( + name='Public key response', + response_only=True, + media_type='application/x-pem-file', + status_codes=['200'], + value=dedent(""" + -----BEGIN PUBLIC KEY----- + MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEmK2L6lwGzSVZwFSo0eR1z4XV6jJwjeWK + YCiPKdMcQnn6u5J016k9U8xZm6XyFnmgvkhnC3wreGBTFzwLCLZCD+F3vo5x8ivz + aTgNWsA3WFlqjSIEGz+PAVHSNMobBaJm + -----END PUBLIC KEY----- + """) + )] + ) + ) + class Fixed(self.target_class): + """ + Get the server's public key in PEM format. + """ + return Fixed + + +class TaskArtifactsExtension(OpenApiViewExtension): + """ + Overrides to rename this endpoint's GET method to ListArtifacts + and fix path parameter typings using a queryset on tasks + """ + target_class = 'ponos.api.TaskArtifacts' + + def view_replacement(self): + from ponos.models import Task + + @extend_schema_view( + get=extend_schema(operation_id='ListArtifacts'), + post=extend_schema(operation_id='CreateArtifact'), + ) + class Fixed(self.target_class): + queryset = Task.objects.none() + + return Fixed + + +class ArtifactSerializerExtension(OpenApiSerializerExtension): + """ + Override `s3_put_url` on artifacts to fix a typing warning + """ + target_class = 'ponos.serializers.ArtifactSerializer' + + def map_serializer(self, auto_schema, direction): + class Fixed(self.target_class): + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_s3_put_url(self, object): + return super().get_s3_put_url(object) + + return auto_schema._map_serializer(Fixed, direction) + + +class AgentDetailsSerializerExtension(OpenApiSerializerExtension): + """ + Override `running_tasks` on artifacts to fix a typing warning + + TODO: This could be fixed on the Ponos side using a property on the agent + """ + target_class = 'ponos.serializers.AgentDetailsSerializer' + + def map_serializer(self, auto_schema, direction): + from ponos.serializers import TaskLightSerializer + + class Fixed(self.target_class): + @extend_schema_field(TaskLightSerializer(many=True)) + def get_running_tasks(self, object): + return super().get_running_tasks(object) + + return auto_schema._map_serializer(Fixed, direction) + + +class ActionSerializerExtension(OpenApiSerializerExtension): + """ + Override `task_id` on actions to fix a typing warning + """ + target_class = 'ponos.serializers.ActionSerializer' + + def map_serializer(self, auto_schema, direction): + class Fixed(self.target_class): + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_task_id(self, object): + return super().get_task_id(object) + + return auto_schema._map_serializer(Fixed, direction) + + +class TaskDefinitionSerializerExtension(OpenApiSerializerExtension): + """ + Override `s3_logs_put_url` and `image_artifact_url` on tasks + to fix typing warnings + """ + target_class = 'ponos.serializers.TaskDefinitionSerializer' + + def map_serializer(self, auto_schema, direction): + class Fixed(self.target_class): + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_s3_logs_put_url(self, object): + return super().get_s3_logs_put_url(object) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_image_artifact_url(self, object): + return super().get_image_artifact_url(object) + + return auto_schema._map_serializer(Fixed, direction) diff --git a/arkindex/project/openapi/generators.py b/arkindex/project/openapi/generators.py deleted file mode 100644 index 1b0402280729410164c1d2d547412d4b43c439ed..0000000000000000000000000000000000000000 --- a/arkindex/project/openapi/generators.py +++ /dev/null @@ -1,52 +0,0 @@ -from pathlib import Path - -import yaml -from django.conf import settings -from rest_framework.schemas.openapi import SchemaGenerator as BaseSchemaGenerator - -PATCH_FILE = Path(__file__).absolute().parent / 'patch.yml' - - -class SchemaGenerator(BaseSchemaGenerator): - """ - Custom schema generator that applies the custom patches from patch.yml. - - Django REST Framework allows generating schemas depending on the authentication; - if a user is not an admin for example, the permissions will be checked, and some - endpoints may be ignored. While this can be useful, it makes schema generation- - specific code in views or serializers, for example for default values, much more - complex as there are more specific cases to gracefully handle, and could require - re-downloading the schema in the API client on each login/logout. - This can also help with caching as the generated schema simply never changes. - - Therefore, to avoid this situation, we ask DRF to generate "public" schemas all - the time, as if we were running manage.py generateschema. - """ - - def get_schema(self, *args, **kwargs): - # Always send public=True and no request - schema = super().get_schema(request=None, public=False) - - with PATCH_FILE.open() as f: - patch = yaml.safe_load(f) - - self.patch_paths(schema['paths'], patch.pop('paths', {})) - schema.update(patch) - schema['info']['version'] = settings.VERSION - - return schema - - def patch_paths(self, paths, patches): - for path_name, methods in patches.items(): - assert path_name in paths, f'OpenAPI path {path_name} does not exist' - path_object = paths[path_name] - - for method, operation_patch in methods.items(): - assert method in path_object, f'Method {method} on OpenAPI path {path_name} does not exist' - operation_object = path_object[method] - - # Update responses separately to allow adding status codes without replacing all of them - if 'responses' in operation_patch and 'responses' in operation_object: - operation_object['responses'].update(operation_patch.pop('responses')) - - operation_object.update(operation_patch) diff --git a/arkindex/project/openapi/hooks.py b/arkindex/project/openapi/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..be75c4dc09e430af73d90ddd86ac57baa39a08c0 --- /dev/null +++ b/arkindex/project/openapi/hooks.py @@ -0,0 +1,127 @@ +import warnings +from pathlib import Path + +import yaml +from drf_spectacular.drainage import error, warn +from drf_spectacular.settings import spectacular_settings +from drf_spectacular.utils import extend_schema, extend_schema_view + +PATCH_FILE = Path(__file__).absolute().parent / 'patch.yml' + + +def apply_openapi_overrides(endpoints, **kwargs): + """ + drf-spectacular pre-processing hook that converts `openapi_overrides` to `@extend_schema` calls. + It explicitly only implements the currently used overrides (operationId, tags) + to try to force developers into using `@extend_schema`.` + """ + for _, _, method, callback in endpoints: + overrides = getattr(callback.view_class, 'openapi_overrides', None) + if not overrides: + continue + + unsupported_keys = set(overrides.keys()) - {'operationId', 'tags'} + if unsupported_keys: + error( + f'The keys {unsupported_keys} are not supported in `openapi_overrides`. ' + 'Please use drf_spectacular schema extension decorators: ' + 'https://drf-spectacular.readthedocs.io/en/latest/customization.html' + ) + + # DeprecationWarnings are hidden by default, so one can use python -Wall when the time comes + # to remove openapi_overrides entirely. + warnings.warn( + f'{callback.view_class.__name__}.openapi_overrides is deprecated. ' + 'Please use drf_spectacular schema extension decorators: ' + 'https://drf-spectacular.readthedocs.io/en/latest/customization.html', + DeprecationWarning + ) + + # Apply @extend_schema_view to let Spectacular deal with applying to a particular method + callback.view_class = extend_schema_view(**{ + method.lower(): extend_schema( + operation_id=overrides.get('operationId'), + tags=overrides.get('tags'), + ) + })(callback.view_class) + + return endpoints + + +def apply_patch_yaml(result, **kwargs): + """ + drf-spectacular post-processing hook that applies the custom patches from patch.yml. + """ + with PATCH_FILE.open() as f: + patch = yaml.safe_load(f) + + for path_name, methods in patch.items(): + if path_name not in result['paths']: + error(f'OpenAPI path {path_name} does not exist') + continue + + path_object = result['paths'][path_name] + + for method, operation_patch in methods.items(): + if method not in path_object: + error(f'Method {method} on OpenAPI path {path_name} does not exist') + continue + + operation_object = path_object[method] + + # Update responses separately to allow adding status codes without replacing all of them + if 'responses' in operation_patch and 'responses' in operation_object: + operation_object['responses'].update(operation_patch.pop('responses')) + + operation_object.update(operation_patch) + + return result + + +def operation_id_pascal_case(result, **kwargs): + """ + Transform camelCased operation IDs into PascalCase + """ + for methods in result['paths'].values(): + for method in methods.values(): + # Ensure there are no 'weird' operation IDs; for example, Spectacular can default to + # snake_cased operation IDs, or cause duplicated operation IDs to have number suffixes + if not method['operationId'].isalpha(): + warn(f'Non-alphabetic characters found in operation ID {method["operationId"]}') + + method['operationId'] = method['operationId'][0].upper() + method['operationId'][1:] + return result + + +def check_tags(result, **kwargs): + """ + Check endpoint tags to require them to be defined in settings, + and require all endpoints to have a tag + """ + known_tags = set(tag['name'] for tag in spectacular_settings.TAGS) + for methods in result['paths'].values(): + for method in methods.values(): + tags = method.get('tags') + + if not tags: + warn(f'Endpoint {method["operationId"]} does not have any assigned tags') + continue + + unknown_tags = set(tags) - known_tags + if unknown_tags: + warn(f'Endpoint {method["operationId"]} uses tags not defined in the project settings: {unknown_tags}') + + return result + + +def request_body_x_name(result, **kwargs): + """ + Adds a `x-name` on request bodies to allow them to be used in APIStar + with the `body={}` keyword argument + """ + for methods in result['paths'].values(): + for method in methods.values(): + if method.get('requestBody'): + method['requestBody']['x-name'] = 'body' + + return result diff --git a/arkindex/project/openapi/patch.yml b/arkindex/project/openapi/patch.yml index 53db57d3d6d29c48b4c453f7d2e569ad83128fac..a8d6d93385550a9e502b554ae1e101be58af07d4 100644 --- a/arkindex/project/openapi/patch.yml +++ b/arkindex/project/openapi/patch.yml @@ -1,494 +1,357 @@ -info: - title: Arkindex API - contact: - name: Teklia - url: https://www.teklia.com/ - email: contact@teklia.com -components: - securitySchemes: - sessionAuth: - in: cookie - name: arkindex.auth - type: apiKey - tokenAuth: - scheme: Token - type: http - agentAuth: - scheme: Bearer - type: http -security: -- tokenAuth: [] -- sessionAuth: [] -servers: - - description: Arkindex - url: https://arkindex.teklia.com - - description: Arkindex preproduction - url: https://preprod.arkindex.teklia.com - x-csrf-cookie: arkindex.preprod.csrf -tags: - - name: corpora - - name: elements - - name: search - - name: oauth - - name: imports - - name: files - - name: images - - name: ponos - - name: iiif - description: IIIF manifests, annotation lists and services - - name: ml - description: Machine Learning tools and results - - name: entities - - name: users - - name: management - description: Admin-only tools -paths: - /api/v1/corpus/: - get: - description: List corpora with their access rights - security: [] - post: - description: Create a new corpus - /api/v1/corpus/{corpus}/elements/: - delete: - operationId: DestroyElements - description: Delete elements in bulk - /api/v1/corpus/{id}/: - get: - description: Retrieve a single corpus - security: [] - put: - description: Update a corpus - patch: - description: Partially update a corpus - delete: - description: >- - Delete a corpus. Requires the "admin" right on the corpus. +# !!!!!!!!!!!!!!!!!!!!!!!!!! DO NOT EDIT THIS FILE !!!!!!!!!!!!!!!!!!!!!!!!!!! +# Both patch.yml and openapi_overrides have been deprecated. +# +# - To update an APIView's schema, use @extend_schema_view and @extend_schema. +# - To update a serializer's schema, use @extend_serializer. +# - To update a serializer field's schema, use @extend_serializer_field. +# - To update a Ponos endpoint, or an endpoint that is not defined anywhere +# in the backend's code, add a Spectacular extension in +# `arkindex.project.openapi.extensions`. +# +# Our docs: https://wiki.vpn/en/arkindex/dev-doc/openapi +# Spectacular docs: +# https://drf-spectacular.readthedocs.io/en/latest/customization.html - This triggers an asynchronous deletion of a corpus and returns a - response immediately; the actual deletion will become visible once - the process is completed. - /api/v1/corpus/{id}/classes/: - post: - operationId: CreateMLClass - /api/v1/corpus/{id}/roles/: - get: - operationId: ListCorpusRoles - description: List all roles of a corpus - security: [] - post: - description: Create a new entity role - responses: - '400': - description: An error occured while creating the role. - content: - application/json: - schema: - properties: - id: - type: string or array - description: The corpus ID. - readOnly: true - corpus: - type: array - description: Errors that occured during corpus ID field validation. - readOnly: true - examples: - role-exists: - summary: Role already exists. - value: - id: 55cd009d-cd4b-4ec2-a475-b060f98f9138 - corpus: - - Role already exists in this corpus - /api/v1/element/{id}/: - get: - description: Retrieve a single element's informations and metadata - security: [] - patch: - description: Rename an element - put: - description: Rename an element - delete: - description: Delete a childless element - /api/v1/element/{id}/metadata/: - get: - operationId: ListElementMetaData - description: List all metadata linked to an element. - post: - operationId: CreateMetaData - description: >- - Create a metadata on an existing element. - - The `date` type allows dates to be parsed and indexed for search APIs. The supported date formats are as follows: - - * `YYYY`: `1995` (recommended) - * `YYYY-MM`: `1995-02` (recommended) - * `YYYY-MM-DD`: `1995-02-16` (recommended) - * `/YYYY(.*)month/`: `1995, february`, with months in French or English (case-insensitive) - * `YYYY-YYYY`: `1995-1996` (yields a date interval) +/api/v1/corpus/: + get: + description: List corpora with their access rights + post: + description: Create a new corpus +/api/v1/corpus/{corpus}/elements/: + delete: + operationId: DestroyElements + description: Delete elements in bulk +/api/v1/corpus/{id}/: + get: + description: Retrieve a single corpus + put: + description: Update a corpus + patch: + description: Partially update a corpus + delete: + description: >- + Delete a corpus. Requires the "admin" right on the corpus. - The `reference` type is also indexed in search APIs and allows searching by - known identifiers or references, or any arbitrary string. - /api/v1/elements/{id}/children/: - delete: - operationId: DestroyElementChildren - description: Delete child elements in bulk - /api/v1/elements/{id}/parents/: - delete: - operationId: DestroyElementParents - description: Delete parent elements in bulk - /api/v1/elements/selection/: - delete: - operationId: RemoveSelection - description: Remove a specific element or delete any selection - requestBody: + This triggers an asynchronous deletion of a corpus and returns a + response immediately; the actual deletion will become visible once + the process is completed. +/api/v1/corpus/{id}/roles/: + get: + operationId: ListCorpusRoles + description: List all roles of a corpus + post: + description: Create a new entity role + responses: + '400': + description: An error occured while creating the role. content: application/json: schema: properties: id: + type: string + description: The corpus ID. + readOnly: true + corpus: + type: array + description: Errors that occured during corpus ID field validation. + readOnly: true + examples: + role-exists: + summary: Role already exists. + value: + id: 55cd009d-cd4b-4ec2-a475-b060f98f9138 + corpus: + - Role already exists in this corpus +/api/v1/element/{id}/: + get: + description: Retrieve a single element's informations and metadata + patch: + description: Rename an element + put: + description: Rename an element + delete: + description: Delete an element +/api/v1/elements/selection/: + delete: + operationId: RemoveSelection + description: Remove a specific element or delete any selection + requestBody: + content: + application/json: + schema: + properties: + id: + format: uuid + type: string + x-name: body + post: + operationId: AddSelection + description: Add specific elements + requestBody: + content: + application/json: + schema: + properties: + ids: + items: format: uuid type: string - x-name: body - get: - operationId: ListSelection - description: List all selected elements - post: - operationId: AddSelection - description: Add specific elements - requestBody: + type: array + uniqueItems: true + required: + - ids + x-name: body +/api/v1/elements/type/{id}/: + patch: + operationId: PartialUpdateElementType +/api/v1/element/{child}/parent/{parent}/: + post: + operationId: CreateElementParent + description: Link an element to a new parent + delete: + operationId: DestroyElementParent + description: Delete the relation between an element and one of its parents +/api/v1/entity/{id}/: + delete: + description: Delete an entity + patch: + description: Partially update an entity + put: + description: Update an entity +/api/v1/image/: + post: + responses: + '400': + description: An error occured while validating the image. content: application/json: schema: properties: - ids: - items: - format: uuid - type: string + detail: + type: string + description: A generic error message when an error occurs outside of a specific field. + readOnly: true + id: + type: string + description: UUID of an existing image, if the error comes from a duplicate hash. + readOnly: true + hash: type: array - uniqueItems: true - required: - - ids - x-name: body - /api/v1/elements/type/{id}/: - patch: - operationId: PartialUpdateElementType - /api/v1/element/{child}/parent/{parent}/: - post: - operationId: CreateElementParent - description: Link an element to a new parent - delete: - operationId: DestroyElementParent - description: Delete the relation between an element and one of its parents - /api/v1/entity/{id}/: - get: - security: [] - delete: - description: Delete an entity - patch: - description: Partially update an entity - put: - description: Update an entity - /api/v1/image/: - post: - responses: - '400': - description: An error occured while validating the image. - content: - application/json: - schema: - properties: - detail: - type: string - description: A generic error message when an error occurs outside of a specific field. - readOnly: true - id: - type: string - description: UUID of an existing image, if the error comes from a duplicate hash. - readOnly: true + description: One or more error messages for errors when validating the image hash. + readOnly: true + examples: + image-exists: + summary: An error where an image with this hash already exists, including the existing image's UUID. + value: hash: - type: array - description: One or more error messages for errors when validating the image hash. - readOnly: true - datafile: - type: array - description: One or more error messages for errors when validating the optional DataFile link. - readOnly: true - examples: - image-exists: - summary: An error where an image with this hash already exists, including the existing image's UUID. - value: - hash: - - Image with this hash already exists - id: 3cc2e9e0-4172-44b1-8d65-bc3fffd076dc - /api/v1/image/{id}/: - get: - description: Retrieve an image - put: - description: Update an image's status - patch: - description: Update an image's status - /api/v1/imports/file/{id}/: - get: - description: Get an uploaded file's metadata - patch: - description: Update a datafile's status - put: - description: Update a datafile's status - delete: - description: Delete an uploaded file - /api/v1/imports/repos/search/{id}/: - post: - description: >- - Using the given OAuth credentials, this links an external Git repository - to Arkindex, connects a push hook and starts an initial import. - /api/v1/imports/repos/{id}/: - delete: - description: Delete a repository - /api/v1/imports/{id}/: - delete: - description: Delete a data import. Cannot be used on currently running data imports. - /api/v1/imports/{id}/workers/: - get: - operationId: ListWorkerRuns - post: - operationId: CreateWorkerRun - description: Create a worker run for a given data import UUID - /api/v1/imports/workers/{id}/: - put: - description: Update a worker run - patch: - description: Partially update a worker run - delete: - description: Delete a worker run - /api/v1/imports/transkribus/: - post: - responses: - '400': - description: An error occured while validating the collection ID. - content: - application/json: - schema: - properties: - collection_id: - type: string - description: Errors that occured during collection ID field validation. - readOnly: true - examples: - user-permission: - summary: An error where the user is not a member of the collection. - value: - collection_id: User user@example.com is not a member of the collection 1 - /api/v1/oauth/credentials/{id}/: - delete: - description: Delete OAuth credentials. This may disable access to some Git repositories. - /api/v1/oauth/providers/{provider}/signin/: - get: - responses: - '200': - content: - application/json: - schema: - properties: - url: - type: string - format: uri - description: URL to the authorization endpoint. - readOnly: true - /api/v1/transcription/{id}/: - get: - description: Retrieve a single transcription - patch: - description: Update the text of a manual transcription - put: - description: Update the text of a manual transcription - delete: - description: Delete a manual transcription - /api/v1/metadata/{id}/: - get: - operationId: RetrieveMetaData - description: Retrieve an existing metadata - patch: - operationId: PartialUpdateMetaData - description: Partially update an existing metadata - put: - operationId: UpdateMetaData - description: Update an existing metadata - delete: - operationId: DestroyMetaData - description: Delete an existing metadata - /api/v1/user/: - get: - description: Retrieve information about the authenticated user - patch: - description: Update a user's password. This action is not allowed to users without confirmed e-mails. - put: - description: Update a user's password. This action is not allowed to users without confirmed e-mails. - delete: - operationId: Logout - description: Log out from the API - /ponos/v1/agent/: - post: - description: Register a Ponos agent - security: [] - tags: - - ponos - /ponos/v1/agent/actions/: - get: - description: Retrieve any actions the current agent should perform - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/agent/refresh/: - post: - operationId: RefreshAgentToken - description: Refresh a Ponos agent token when it expires - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/public-key/: - get: - operationId: GetPublicKey - description: Get the server's public key. - security: [] - tags: - - ponos - responses: - '200': - content: - application/x-pem-file: - schema: - type: string - example: |- - -----BEGIN PUBLIC KEY----- - MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEmK2L6lwGzSVZwFSo0eR1z4XV6jJwjeWK - YCiPKdMcQnn6u5J016k9U8xZm6XyFnmgvkhnC3wreGBTFzwLCLZCD+F3vo5x8ivz - aTgNWsA3WFlqjSIEGz+PAVHSNMobBaJm - -----END PUBLIC KEY----- - /ponos/v1/task/{id}/: - put: - description: Update a task, allowing humans to change the task's state - operationId: UpdateTask - security: [] - tags: - - ponos - patch: - description: Partially update a task, allowing humans to change the task's state - operationId: PartialUpdateTask - security: [] - tags: - - ponos - /ponos/v1/task/{id}/from-agent/: - get: - description: Retrieve a Ponos task status - operationId: RetrieveTaskFromAgent - security: [] - tags: - - ponos - put: - description: Update a task, from an agent - operationId: UpdateTaskFromAgent - security: - - agentAuth: [] - tags: - - ponos - patch: - description: Partially update a task, from an agent - operationId: PartialUpdateTaskFromAgent - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/task/{id}/artifacts/: - get: - description: List all the artifacts of the task - security: [] - tags: - - ponos - post: - description: Create an artifact on a task - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/task/{id}/definition/: - get: - description: Retrieve a Ponos task - operationId: RetrieveTaskDefinition - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/workflow/{id}/: - get: - description: Retrieve a Ponos workflow status - security: [] - tags: - - ponos - patch: - description: Partially update a workflow - tags: + - Image with this hash already exists + id: 3cc2e9e0-4172-44b1-8d65-bc3fffd076dc +/api/v1/image/{id}/: + get: + description: Retrieve an image + put: + description: Update an image's status + patch: + description: Update an image's status +/api/v1/imports/file/{id}/: + get: + description: Get an uploaded file's metadata + patch: + description: Update a datafile's status + put: + description: Update a datafile's status + delete: + description: Delete an uploaded file +/api/v1/imports/repos/{id}/: + delete: + description: Delete a repository +/api/v1/imports/{id}/: + delete: + description: Delete a data import. Cannot be used on currently running data imports. +/api/v1/imports/{id}/workers/: + get: + operationId: ListWorkerRuns + post: + operationId: CreateWorkerRun + description: Create a worker run for a given data import UUID +/api/v1/imports/workers/{id}/: + put: + description: Update a worker run + patch: + description: Partially update a worker run + delete: + description: Delete a worker run +/api/v1/imports/transkribus/: + post: + responses: + '400': + description: An error occured while validating the collection ID. + content: + application/json: + schema: + properties: + collection_id: + type: string + description: Errors that occured during collection ID field validation. + readOnly: true + examples: + user-permission: + summary: An error where the user is not a member of the collection. + value: + collection_id: User user@example.com is not a member of the collection 1 +/api/v1/oauth/credentials/{id}/: + delete: + description: Delete OAuth credentials. This may disable access to some Git repositories. +/api/v1/oauth/providers/{provider}/signin/: + get: + responses: + '200': + description: '' + content: + application/json: + schema: + properties: + url: + type: string + format: uri + description: URL to the authorization endpoint. + readOnly: true +/api/v1/metadata/{id}/: + get: + operationId: RetrieveMetaData + description: Retrieve an existing metadata + patch: + operationId: PartialUpdateMetaData + description: Partially update an existing metadata + put: + operationId: UpdateMetaData + description: Update an existing metadata + delete: + operationId: DestroyMetaData + description: Delete an existing metadata +/api/v1/user/: + get: + description: Retrieve information about the authenticated user + patch: + description: Update a user's password. This action is not allowed to users without confirmed e-mails. + put: + description: Update a user's password. This action is not allowed to users without confirmed e-mails. + delete: + operationId: Logout + description: Log out from the API +/ponos/v1/agent/: + post: + description: Register a Ponos agent + tags: + - ponos +/ponos/v1/agent/actions/: + get: + description: Retrieve any actions the current agent should perform + tags: + - ponos +/ponos/v1/agent/refresh/: + post: + operationId: RefreshAgentToken + description: Refresh a Ponos agent token when it expires + tags: + - ponos +/ponos/v1/task/{id}/: + put: + description: Update a task, allowing humans to change the task's state + operationId: UpdateTask + tags: + - ponos + patch: + description: Partially update a task, allowing humans to change the task's state + operationId: PartialUpdateTask + tags: + - ponos +/ponos/v1/task/{id}/from-agent/: + get: + description: Retrieve a Ponos task status + operationId: RetrieveTaskFromAgent + tags: + - ponos + put: + description: Update a task, from an agent + operationId: UpdateTaskFromAgent + tags: + - ponos + patch: + description: Partially update a task, from an agent + operationId: PartialUpdateTaskFromAgent + tags: + - ponos +/ponos/v1/task/{id}/artifacts/: + get: + description: List all the artifacts of the task + tags: + - ponos + post: + description: Create an artifact on a task + tags: + - ponos +/ponos/v1/task/{id}/definition/: + get: + description: Retrieve a Ponos task + operationId: RetrieveTaskDefinition + tags: + - ponos +/ponos/v1/workflow/{id}/: + get: + description: Retrieve a Ponos workflow status + tags: + - ponos + patch: + description: Partially update a workflow + tags: + - ponos + put: + description: Update a workflow's status and tasks + tags: + - ponos +/ponos/v1/task/: + post: + description: Create a task with a parent + operationId: CreateNewTask + tags: + - ponos +/ponos/v1/secret/{name}: + get: + description: Retrieve a Ponos secret content as cleartext + operationId: RetrieveSecret + tags: + - ponos +/ponos/v1/agents/: + get: + description: List the state of all Ponos agents + operationId: ListAgentStates + tags: - ponos - put: - description: Update a workflow's status and tasks - tags: +/ponos/v1/agent/{id}/: + get: + description: Retrieve details of a Ponos agent with a list of its running tasks + operationId: RetrieveAgent + tags: - ponos - /ponos/v1/task/{id}/artifact/{path}: - get: - description: Redirect to the S3 url of an artifact in order to download it - operationId: DownloadArtifact - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/task/: - post: - description: Create a task with a parent - operationId: CreateNewTask - security: - - agentAuth: [] - tags: - - ponos - /ponos/v1/secret/{name}: - get: - description: Retrieve a Ponos secret content as cleartext - operationId: RetrieveSecret - security: [] - tags: - - ponos - /ponos/v1/agents/: - get: - description: List the state of all Ponos agents - operationId: ListAgentStates - security: [] - tags: - - ponos - /ponos/v1/agent/{id}/: - get: - description: Retrieve details of a Ponos agent with a list of its running tasks - operationId: RetrieveAgent - security: [] - tags: - - ponos - /api/v1/group/{id}/: - patch: - description: >- - Partially update details of a group. - Requires to have `admin` privileges on this group. - put: - description: >- - Update details of a group. - Requires to have `admin` privileges on this group. - delete: - description: >- - Delete a group. - Requires to have `admin` privileges on this group. - /api/v1/membership/{id}/: - patch: - description: Partially update a generic membership. - put: - description: Update a generic membership. - delete: - description: Delete a generic membership. +/api/v1/group/{id}/: + patch: + description: >- + Partially update details of a group. + Requires to have `admin` privileges on this group. + put: + description: >- + Update details of a group. + Requires to have `admin` privileges on this group. + delete: + description: >- + Delete a group. + Requires to have `admin` privileges on this group. +/api/v1/membership/{id}/: + patch: + description: Partially update a generic membership. + put: + description: Update a generic membership. + delete: + description: Delete a generic membership. diff --git a/arkindex/project/openapi/schema.py b/arkindex/project/openapi/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..34faf2df86fdfbeadf5d173fbd3eb693dbeb6a57 --- /dev/null +++ b/arkindex/project/openapi/schema.py @@ -0,0 +1,27 @@ +from drf_spectacular.openapi import AutoSchema as BaseAutoSchema +from rest_framework.schemas.openapi import AutoSchema as DRFAutoSchema + + +class AutoSchema(BaseAutoSchema): + + def get_operation_id(self): + """ + Spectacular generates operation IDs from URLs with the action as the last item. + For example, `POST /worker/{id}/versions/` for a list API becomes `worker_versions_create`. + However, we want `CreateWorkerVersion`, without a S, with worker_version coming from a + Model, a Serializer or an APIView name; DRF's original logic. + To do so, we actually call DRF's original logic. + + Note that upgrading to DRF 3.12 will require changing _get_operation_id to get_operation_id. + """ + schema = DRFAutoSchema() + schema.view = self.view + return schema._get_operation_id(self.path, self.method) + + def get_tags(self): + """ + By default, Spectacular picks the first 'subfolder' in URLs as the default tag, + for example /api/v1/corpus/<id> picks ['corpus'] as tags. + We want to specify tags manually, so we return nothing instead. + """ + return [] diff --git a/arkindex/project/openapi/schemas.py b/arkindex/project/openapi/schemas.py deleted file mode 100644 index a813b80fb83a64938682db47bbe6cc834a1238b3..0000000000000000000000000000000000000000 --- a/arkindex/project/openapi/schemas.py +++ /dev/null @@ -1,119 +0,0 @@ -import warnings -from enum import Enum - -from rest_framework.schemas.openapi import AutoSchema as BaseAutoSchema - - -class AutoSchema(BaseAutoSchema): - """ - A custom view schema generator. - The docs on OpenAPI generation are currently very incomplete. - If you need to implement more features to avoid the "patch.yml" and allow - views to customize their OpenAPI schemas by themselves, you may see for - yourself how DRF does the schema generation: - https://github.com/encode/django-rest-framework/blob/master/rest_framework/schemas/openapi.py - """ - - def _map_serializer(self, serializer): - """ - This is a temporary patch because the default schema generation does not handle - callable defaults in fields properly, and adds 'default=null' to any field that - does not have a default value, even required fields. - - https://github.com/encode/django-rest-framework/issues/6858 - """ - schema = super()._map_serializer(serializer) - for field_name in schema['properties']: - if 'default' not in schema['properties'][field_name]: - continue - default = schema['properties'][field_name]['default'] - - if hasattr(default, 'openapi_value'): - # Allow a 'openapi_value' attribute on the default for custom defaults - default = default.openapi_value - - elif callable(default): - # Try to call the callable default; if it does not work, warn, then remove it - try: - default = default() - except Exception as e: - warnings.warn('Unsupported callable default for field {}: {!s}'.format(field_name, e)) - del schema['properties'][field_name]['default'] - continue - - elif isinstance(default, Enum): - # Convert enums into their string values - default = default.value - - # Remove null defaults on required fields - if default is None and field_name in schema.get('required', []): - del schema['properties'][field_name]['default'] - else: - schema['properties'][field_name]['default'] = default - - return schema - - def _get_request_body(self, path, method): - """ - Add x-name to requestBody objects to allow APIStar to use `body={}` arguments on requests - """ - request_body = super()._get_request_body(path, method) - if not request_body: - return request_body - assert 'x-name' not in request_body - request_body['x-name'] = 'body' - return request_body - - def get_operation(self, path, method): - operation = super().get_operation(path, method) - - # Operation IDs for list endpoints are improperly cased: listThings instead of ListThings - # https://github.com/encode/django-rest-framework/pull/6917 - if operation['operationId'][0].islower(): - operation['operationId'] = operation['operationId'][0].upper() + operation['operationId'][1:] - - # Setting deprecated = True on a View makes it deprecated in OpenAPI - if getattr(self.view, 'deprecated', False): - operation['deprecated'] = True - - # Allow an `openapi_overrides` attribute to override the operation's properties - # TODO: Quite crude, not enough to make overriding request/response bodies easy - if hasattr(self.view, 'openapi_overrides'): - # Avoid removing the parameter overrides with .pop() - overrides = self.view.openapi_overrides.copy() - if 'parameters' in overrides: - # Filter parameters by HTTP method - allowed_parameters = [ - parameter for parameter in overrides.pop('parameters') - if 'x-methods' not in parameter or method.lower() in parameter['x-methods'] - ] - # Append parameters instead of replacing - operation.setdefault('parameters', []).extend(allowed_parameters) - operation.update(overrides) - - return operation - - -class SearchAutoSchema(AutoSchema): - """ - A special schema generators that parses a search query serializer into query string params, - to allow API clients to perform searches. - """ - - def _get_filter_parameters(self, path, method): - parameters = super()._get_filter_parameters(path, method) - serializer_class = getattr(self.view, 'query_serializer_class', None) - if not serializer_class: - return parameters - serializer = serializer_class() - serializer_schema = self._map_serializer(serializer) - return parameters + [ - { - 'name': field_name, - 'in': 'query', - 'required': field_name in serializer_schema.get('required', []), - 'description': '', - 'schema': field_schema, - } - for field_name, field_schema in serializer_schema['properties'].items() - ] diff --git a/arkindex/project/openapi/views.py b/arkindex/project/openapi/views.py new file mode 100644 index 0000000000000000000000000000000000000000..7f0b3f0525a1872cb39f754492bfcfc8f7aee268 --- /dev/null +++ b/arkindex/project/openapi/views.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from drf_spectacular.renderers import OpenApiJsonRenderer, OpenApiYamlRenderer +from drf_spectacular.utils import extend_schema +from drf_spectacular.views import SpectacularAPIView + + +class OldOpenApiYamlRenderer(OpenApiYamlRenderer): + """ + Restores DRF's `?format=openapi` for backwards compatibility + """ + format = 'openapi' + + +class OldOpenApiJsonRenderer(OpenApiJsonRenderer): + """ + Restores DRF's `?format=openapi-json` for backwards compatibility + """ + format = 'openapi-json' + + +@method_decorator(cache_page(86400, key_prefix=settings.VERSION), name='dispatch') +@extend_schema(exclude=True) +class OpenApiSchemaView(SpectacularAPIView): + renderer_classes = SpectacularAPIView.renderer_classes + [ + OldOpenApiYamlRenderer, + OldOpenApiJsonRenderer, + ] diff --git a/arkindex/project/settings.py b/arkindex/project/settings.py index dc5ce3230fa173c3744925516ffe8bea02edfe75..675ff9819cec42b434b92dcf5ca8975ea9f15a97 100644 --- a/arkindex/project/settings.py +++ b/arkindex/project/settings.py @@ -101,6 +101,7 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'corsheaders', 'django_rq', + 'drf_spectacular', 'ponos', # Our apps @@ -210,6 +211,73 @@ SIMPLE_JWT = { 'SIGNING_KEY': conf['jwt_signing_key'] or SECRET_KEY, } +SPECTACULAR_SETTINGS = { + 'CAMELIZE_NAMES': True, + 'SCHEMA_PATH_PREFIX': '/api/v1/', + 'TITLE': 'Arkindex API', + 'CONTACT': { + 'name': 'Teklia', + 'url': 'https://www.teklia.com/', + 'email': 'contact@teklia.com', + }, + 'VERSION': VERSION, + 'EXTERNAL_DOCS': { + 'url': 'https://teklia.com/arkindex/presentation/', + 'description': 'Arkindex documentation' + }, + 'PREPROCESSING_HOOKS': [ + 'arkindex.project.openapi.apply_openapi_overrides', + ], + 'POSTPROCESSING_HOOKS': [ + 'drf_spectacular.hooks.postprocess_schema_enums', + 'arkindex.project.openapi.apply_patch_yaml', + 'arkindex.project.openapi.operation_id_pascal_case', + 'arkindex.project.openapi.check_tags', + 'arkindex.project.openapi.request_body_x_name', + ], + # Spectacular generates enum names from field names, so if two different enums are linked + # to the same field name (e.g. MetaType and EntityType on `type` fields of different serializers), + # Spectacular can warn about duplicates. + # To fix this, the entries below map custom names to each affected enum. + 'ENUM_NAME_OVERRIDES': { + 'MLToolType': 'arkindex_common.ml_tool.MLToolType', + 'MetaType': 'arkindex_common.enums.MetaType', + 'ProcessMode': 'arkindex_common.enums.DataImportMode', + 'EntityType': 'arkindex_common.enums.EntityType', + 'S3FileStatus': 'arkindex.project.aws.S3FileStatus', + 'ClassificationState': 'arkindex.documents.models.ClassificationState', + 'PonosState': 'ponos.models.State', + }, + 'TAGS': [ + {'name': 'classifications'}, + {'name': 'corpora'}, + {'name': 'elements'}, + {'name': 'entities'}, + {'name': 'files'}, + { + 'name': 'iiif', + 'description': 'IIIF manifests, annotation lists and services', + }, + {'name': 'imports'}, + {'name': 'images'}, + {'name': 'jobs'}, + { + 'name': 'ml', + 'description': 'Machine Learning tools and results', + }, + {'name': 'oauth'}, + {'name': 'ponos'}, + {'name': 'repos'}, + {'name': 'search'}, + {'name': 'transcriptions'}, + {'name': 'users'}, + { + 'name': 'management', + 'description': 'Admin-only tools', + }, + ] +} + SEARCH_FILTER_MAX_TERMS = 10 # Elastic search config diff --git a/arkindex/project/tests/openapi/test_generators.py b/arkindex/project/tests/openapi/test_generators.py deleted file mode 100644 index 46b40f9be4ca03836f9d7469c83fdcade8dacfc7..0000000000000000000000000000000000000000 --- a/arkindex/project/tests/openapi/test_generators.py +++ /dev/null @@ -1,93 +0,0 @@ -from unittest import TestCase -from unittest.mock import patch - -from django.test import override_settings - -from arkindex.project.openapi import SchemaGenerator - - -class TestSchemaGenerator(TestCase): - - @override_settings(VERSION='0.0') - @patch('arkindex.project.openapi.generators.yaml.safe_load', return_value={}) - def test_auto_version(self, mock): - """ - Ensure the schema version is set to the auto-detected backend's version - """ - schema = SchemaGenerator().get_schema() - self.assertEqual(schema['info']['version'], '0.0') - - def test_patch_paths_assertions(self): - gen = SchemaGenerator() - - with self.assertRaisesRegex(AssertionError, 'OpenAPI path /somewhere/ does not exist'): - gen.patch_paths( - paths={}, - patches={'/somewhere/': {}} - ) - - with self.assertRaisesRegex(AssertionError, 'Method patch on OpenAPI path /somewhere/ does not exist'): - gen.patch_paths( - paths={ - '/somewhere/': { - 'get': {} - } - }, - patches={ - '/somewhere/': { - 'patch': {} - } - } - ) - - def test_patch_paths(self): - patches = { - '/somewhere/': { - 'get': { - 'operationId': 'PatchedSomewhere' - }, - 'post': { - 'responses': { - '409': { - 'description': 'Conflict' - } - } - } - } - } - expected = { - '/somewhere/': { - 'get': { - 'operationId': 'PatchedSomewhere' - }, - 'post': { - 'operationId': 'PostSomewhere', - 'responses': { - '418': { - 'description': "I'm a teapot" - }, - '409': { - 'description': 'Conflict' - } - } - } - } - } - actual = { - '/somewhere/': { - 'get': { - 'operationId': 'GetSomewhere', - }, - 'post': { - 'operationId': 'PostSomewhere', - 'responses': { - '418': { - 'description': "I'm a teapot", - } - } - } - } - } - SchemaGenerator().patch_paths(actual, patches) - - self.assertDictEqual(actual, expected) diff --git a/arkindex/project/tests/openapi/test_hooks.py b/arkindex/project/tests/openapi/test_hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..2820d8be835c610c24355d88afc7e779e23c08f7 --- /dev/null +++ b/arkindex/project/tests/openapi/test_hooks.py @@ -0,0 +1,264 @@ +from unittest import TestCase +from unittest.mock import call, patch + +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.plumbing import ComponentRegistry +from rest_framework.views import APIView + +from arkindex.project.openapi import ( + apply_openapi_overrides, + apply_patch_yaml, + check_tags, + operation_id_pascal_case, + request_body_x_name, +) + + +@patch('arkindex.project.openapi.hooks.warn') +@patch('arkindex.project.openapi.hooks.error') +class TestSpectacularHooks(TestCase): + + def test_openapi_overrides(self, error_mock, warn_mock): + """ + Test the apply_openapi_overrides pre-processing hook that should allow + `operationId` and `tags` and cause Spectacular errors for other keys. + For any override, it also should cause a Python DeprecationWarning. + """ + class MyAPIView(APIView): + openapi_overrides = { + 'operationId': 'blah', + 'tags': ['a', 'b'], + 'bad': 'oh no', + } + + def get(self, *args, **kwargs): + pass + + with self.assertWarns(DeprecationWarning) as context: + result = apply_openapi_overrides([('/a', '/a', 'get', MyAPIView.as_view())]) + + self.assertEqual( + context.warning.args[0], + 'MyAPIView.openapi_overrides is deprecated. ' + 'Please use drf_spectacular schema extension decorators: ' + 'https://drf-spectacular.readthedocs.io/en/latest/customization.html' + ) + + view = SchemaGenerator().create_view(result[0][3], 'get', None) + operation = view.schema.get_operation('/a', '/a', 'get', ComponentRegistry()) + + self.assertEqual(operation['operationId'], 'blah') + self.assertListEqual(operation['tags'], ['a', 'b']) + self.assertNotIn('bad', operation) + + self.assertEqual(error_mock.call_count, 1) + self.assertEqual(error_mock.call_args, call( + "The keys {'bad'} are not supported in `openapi_overrides`. " + "Please use drf_spectacular schema extension decorators: " + "https://drf-spectacular.readthedocs.io/en/latest/customization.html" + )) + + self.assertFalse(warn_mock.called) + + @patch('arkindex.project.openapi.hooks.yaml.safe_load') + def test_patch_yaml(self, yaml_mock, error_mock, warn_mock): + """ + Test the patch_yaml post-processing hook that should override existing + operation objects and cause errors for non-existent operations. + It should also extend, instead of override, the responses to allow adding + response codes without removing the existing ones. + """ + schema = { + 'paths': { + '/a': { + 'get': { + 'operationId': 'RetrieveA', + 'responses': { + '200': { + 'type': 'object' + } + } + } + } + } + } + + yaml_mock.return_value = { + '/a': { + 'get': { + 'operationId': 'ListA', + 'tags': ['a'], + # Should extend, not override responses + 'responses': { + '418': { + 'type': 'string' + } + } + }, + # Non-existent method + 'post': { + 'operationId': 'CreateA', + } + }, + # Non-existent path + '/b': { + 'get': { + 'operationId': 'RetrieveB', + } + } + } + + schema = apply_patch_yaml(schema) + + self.assertDictEqual(schema, { + 'paths': { + '/a': { + 'get': { + 'operationId': 'ListA', + 'tags': ['a'], + 'responses': { + '200': { + 'type': 'object' + }, + '418': { + 'type': 'string' + } + } + } + } + } + }) + + self.assertListEqual(error_mock.call_args_list, [ + call('Method post on OpenAPI path /a does not exist'), + call('OpenAPI path /b does not exist'), + ]) + self.assertFalse(warn_mock.called) + + def test_operation_id_pascal_case(self, error_mock, warn_mock): + """ + Test the operation_id_pascal_case post-processing hook that should + turn camelCase operation IDs generated by Spectacular into PascalCase + and warn about non-alphabetic IDs, as duplicate IDs can be resolved + by Spectacular with numeric suffixes. + """ + schema = operation_id_pascal_case({ + 'paths': { + '/a': { + 'get': { + 'operationId': 'retrieveA', + }, + 'post': { + 'operationId': 'CreateA', + }, + 'put': { + 'operationId': 'createA2', + } + } + } + }) + self.assertDictEqual(schema, { + 'paths': { + '/a': { + 'get': { + 'operationId': 'RetrieveA', + }, + 'post': { + 'operationId': 'CreateA', + }, + 'put': { + 'operationId': 'CreateA2', + } + } + } + }) + self.assertFalse(error_mock.called) + self.assertEqual(warn_mock.call_count, 1) + self.assertEqual(warn_mock.call_args, call( + 'Non-alphabetic characters found in operation ID createA2' + )) + + @patch('arkindex.project.openapi.hooks.spectacular_settings.TAGS', [{'name': 'a'}]) + def test_check_tags(self, error_mock, warn_mock): + """ + Test the check_tags post-processing hook that should ensure each + endpoint has at least one tag and only uses tags defined in + arkindex.project.settings. + """ + check_tags({ + 'paths': { + '/a': { + 'get': { + 'operationId': 'RetrieveA', + }, + 'post': { + 'operationId': 'CreateA', + 'tags': ['a'], + }, + 'delete': { + 'operationId': 'DestroyA', + 'tags': ['a', 'b'], + } + } + } + }) + self.assertFalse(error_mock.called) + self.assertListEqual(warn_mock.call_args_list, [ + call('Endpoint RetrieveA does not have any assigned tags'), + call("Endpoint DestroyA uses tags not defined in the project settings: {'b'}") + ]) + + def test_request_body_x_name(self, error_mock, warn_mock): + """ + Test the request_body_x_name post-processing hook adds x-name: body to all request bodies + """ + schema = request_body_x_name({ + 'paths': { + '/a': { + 'post': { + 'requestBody': { + 'content': { + 'application/json': { + 'schema': {'type': 'object'} + } + } + } + }, + 'delete': { + 'requestBody': { + 'content': { + 'application/json': { + 'schema': {'type': 'object'} + } + } + } + } + } + } + }) + self.assertDictEqual(schema, { + 'paths': { + '/a': { + 'post': { + 'requestBody': { + 'content': { + 'application/json': { + 'schema': {'type': 'object'} + } + }, + 'x-name': 'body' + } + }, + 'delete': { + 'requestBody': { + 'content': { + 'application/json': { + 'schema': {'type': 'object'} + } + }, + 'x-name': 'body' + } + } + } + } + }) diff --git a/arkindex/project/tests/openapi/test_schema.py b/arkindex/project/tests/openapi/test_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..c0c91fa318bc148aef613f20eb7133bbfab2e682 --- /dev/null +++ b/arkindex/project/tests/openapi/test_schema.py @@ -0,0 +1,64 @@ +from unittest import TestCase + +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.plumbing import ComponentRegistry, ResolvedComponent +from rest_framework.views import APIView + +from arkindex.project.mixins import DeprecatedMixin +from arkindex.project.openapi import AutoSchema + + +class TestAutoSchema(TestCase): + + def test_get_tags(self): + """ + Test AutoSchema does not produce any tags automatically + (must be manually defined on each endpoint) + """ + self.assertListEqual(AutoSchema().get_tags(), []) + + def test_deprecated_response(self): + """ + Test the DeprecatedAutoSchema marks everything as deprecated + """ + class ThingView(DeprecatedMixin, APIView): + + def get(self, *args, **kwargs): + pass + + registry = ComponentRegistry() + view = SchemaGenerator().create_view(ThingView.as_view(), 'GET', None) + operation = view.schema.get_operation('/test/', '/test/', 'GET', registry) + + self.assertDictEqual( + operation, + { + 'operationId': 'listThings', + 'deprecated': True, + 'description': '', + 'responses': { + '410': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/DeprecatedException'} + } + }, + 'description': '', + } + }, + 'security': [ + {'cookieAuth': []}, + {'tokenAuth': []}, + {'agentAuth': []} + ], + } + ) + self.assertDictEqual(registry[('DeprecatedException', ResolvedComponent.SCHEMA)].schema, { + 'type': 'object', + 'required': ['detail'], + 'properties': { + 'detail': { + 'type': 'string', + }, + }, + }) diff --git a/arkindex/project/tests/openapi/test_schemas.py b/arkindex/project/tests/openapi/test_schemas.py deleted file mode 100644 index 79ef30012f40e174449906840c9c348eb302e490..0000000000000000000000000000000000000000 --- a/arkindex/project/tests/openapi/test_schemas.py +++ /dev/null @@ -1,476 +0,0 @@ -import warnings -from unittest import TestCase - -from django.test import RequestFactory -from rest_framework import serializers -from rest_framework.request import Request -from rest_framework.views import APIView - -from arkindex.project.mixins import DeprecatedAutoSchema -from arkindex.project.openapi import AutoSchema, SchemaGenerator -from arkindex.project.serializer_fields import EnumField -from arkindex_common.enums import DataImportMode - - -# Helper methods taken straight from the DRF test suite -def create_request(path): - factory = RequestFactory() - request = Request(factory.get(path)) - return request - - -def create_view(view_cls, method, request): - generator = SchemaGenerator() - view = generator.create_view(view_cls.as_view(), method, request) - return view - - -class TestAutoSchema(TestCase): - - def test_no_deprecated(self): - """ - Test deprecated is omitted in schemas if the attribute is absent - """ - - class ThingView(APIView): - action = 'Retrieve' - - def get(self, *args, **kwargs): - pass - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'GET', create_request('/test/')) - self.assertDictEqual( - inspector.get_operation('/test/', 'GET'), - { - 'operationId': 'RetrieveThing', - 'description': '', - 'parameters': [], - 'responses': { - '200': { - 'content': { - 'application/json': { - 'schema': {} - } - }, - 'description': '', - } - } - } - ) - - def test_deprecated_attribute(self): - """ - Test the optional `deprecated` attribute on views - """ - - class ThingView(APIView): - action = 'Retrieve' - deprecated = True - - def get(self, *args, **kwargs): - pass - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'GET', create_request('/test/')) - self.assertDictEqual( - inspector.get_operation('/test/', 'GET'), - { - 'operationId': 'RetrieveThing', - 'description': '', - 'parameters': [], - 'responses': { - '200': { - 'content': { - 'application/json': { - 'schema': {} - } - }, - 'description': '', - } - }, - 'deprecated': True, - } - ) - - def test_overrides(self): - """ - Test the optional `openapi_overrides` attribute on views - """ - - class ThingView(APIView): - action = 'Retrieve' - openapi_overrides = { - 'operationId': 'HaltAndCatchFire', - 'description': '', - 'tags': ['bad-ideas'], - 'parameters': [ - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something', - 'required': False, - 'schema': {'type': 'integer'}, - } - ], - } - - def get(self, *args, **kwargs): - pass - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'GET', create_request('/test/{id}/')) - self.assertDictEqual( - inspector.get_operation('/test/{id}/', 'GET'), - { - 'operationId': 'HaltAndCatchFire', - 'description': '', - 'parameters': [ - { - 'description': '', - 'in': 'path', - 'name': 'id', - 'required': True, - 'schema': {'type': 'string'}, - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something', - 'required': False, - 'schema': {'type': 'integer'}, - } - ], - 'responses': { - '200': { - 'content': { - 'application/json': { - 'schema': {} - } - }, - 'description': '', - } - }, - 'tags': ['bad-ideas'], - } - ) - - def test_overrides_method_filter(self): - """ - Test the `x-methods` filter for parameters on openapi_overrides - """ - - class ThingView(APIView): - action = 'Retrieve' - openapi_overrides = { - 'parameters': [ - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something_get', - 'required': False, - 'schema': {'type': 'integer'}, - 'x-methods': ['get'], - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something_post', - 'required': False, - 'schema': {'type': 'integer'}, - 'x-methods': ['post', 'delete'], - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something', - 'required': False, - 'schema': {'type': 'integer'}, - } - ], - } - - def get(self, *args, **kwargs): - pass - - def post(self, *args, **kwargs): - pass - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'GET', create_request('/test/{id}/')) - self.assertListEqual( - inspector.get_operation('/test/{id}/', 'GET')['parameters'], - [ - { - 'description': '', - 'in': 'path', - 'name': 'id', - 'required': True, - 'schema': {'type': 'string'}, - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something_get', - 'required': False, - 'schema': {'type': 'integer'}, - 'x-methods': ['get'], - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something', - 'required': False, - 'schema': {'type': 'integer'}, - } - ], - ) - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'POST', create_request('/test/{id}/')) - self.assertListEqual( - inspector.get_operation('/test/{id}/', 'POST')['parameters'], - [ - { - 'description': '', - 'in': 'path', - 'name': 'id', - 'required': True, - 'schema': {'type': 'string'}, - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something_post', - 'required': False, - 'schema': {'type': 'integer'}, - 'x-methods': ['post', 'delete'], - }, - { - 'description': 'Some extra parameter', - 'in': 'query', - 'name': 'something', - 'required': False, - 'schema': {'type': 'integer'}, - } - ], - ) - - def test_bugfix_list_uppercase(self): - """ - Test list API views have title-cased endpoint names - """ - - class ThingView(APIView): - action = 'list' - - def get(self, *args, **kwargs): - pass - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'GET', create_request('/test/')) - self.assertDictEqual( - inspector.get_operation('/test/', 'GET'), - { - 'operationId': 'ListThings', - 'description': '', - 'parameters': [], - 'responses': { - '200': { - 'content': { - 'application/json': { - 'schema': { - 'type': 'array', - 'items': {}, - } - } - }, - 'description': '', - } - }, - } - ) - - def test_bugfix_callable_defaults(self): - """ - Test the temporary fix to convert callable defaults into their results - """ - - def bad_default(): - raise Exception('Nope') - - def fancy_default(): - raise Exception("Don't touch me!") - - fancy_default.openapi_value = 1337.0 - - class ThingSerializer(serializers.Serializer): - my_field = serializers.FloatField(default=float) - my_field_on_fire = serializers.FloatField(default=bad_default) - my_fancy_field = serializers.FloatField(default=fancy_default) - - inspector = AutoSchema() - with warnings.catch_warnings(record=True) as warn_list: - schema = inspector._map_serializer(ThingSerializer()) - - self.assertEqual(len(warn_list), 1) - self.assertEqual( - str(warn_list[0].message), - 'Unsupported callable default for field my_field_on_fire: Nope', - ) - - self.assertDictEqual(schema, { - 'properties': { - 'my_field': { - 'type': 'number', - 'default': 0.0, - }, - 'my_field_on_fire': { - 'type': 'number', - }, - 'my_fancy_field': { - 'type': 'number', - 'default': 1337.0, - }, - }, - }) - - def test_bugfix_enum_defaults(self): - """ - Test the temporary fix to convert enums into strings - """ - - class ThingSerializer(serializers.Serializer): - my_field = EnumField(DataImportMode, default=DataImportMode.Images) - - inspector = AutoSchema() - self.assertDictEqual(inspector._map_serializer(ThingSerializer()), { - 'properties': { - 'my_field': { - 'default': 'images', - 'enum': [ - 'images', - 'pdf', - 'repository', - 'elements', - 'iiif', - 'workers', - 'transkribus', - ] - } - } - }) - - def test_bugfix_null_defaults(self): - """ - Test the temporary fix that hides None defaults when they do not actually exist - """ - - class ThingSerializer(serializers.Serializer): - my_field = serializers.CharField() - - inspector = AutoSchema() - self.assertDictEqual(inspector._map_serializer(ThingSerializer()), { - 'properties': { - 'my_field': { - 'type': 'string', - } - }, - 'required': ['my_field'], - }) - - def test_request_body_apistar_compat(self): - """ - Test x-name is added to requestBody to allow APIStar to use the request body as `body={}` - """ - - class ThingSerializer(serializers.Serializer): - my_field = serializers.CharField() - - class ThingView(APIView): - serializer_class = ThingSerializer - - def get_serializer(self): - return self.serializer_class() - - def post(self, *args, **kwargs): - pass - - inspector = AutoSchema() - inspector.view = create_view(ThingView, 'POST', create_request('/test/')) - expected_schema = { - 'schema': { - 'properties': { - 'my_field': { - 'type': 'string' - } - }, - 'required': ['my_field'] - } - } - self.assertDictEqual( - inspector.get_operation('/test/', 'POST'), - { - 'operationId': 'CreateThing', - 'description': '', - 'parameters': [], - 'requestBody': { - 'content': { - 'application/json': expected_schema, - 'application/x-www-form-urlencoded': expected_schema, - 'multipart/form-data': expected_schema - }, - 'x-name': 'body', - }, - 'responses': { - '200': { - 'content': { - 'application/json': expected_schema, - }, - 'description': '', - } - }, - } - ) - - def test_deprecated_response(self): - """ - Test the DeprecatedAutoSchema marks everything as deprecated - """ - class ThingView(APIView): - - def get(self, *args, **kwargs): - pass - - inspector = DeprecatedAutoSchema() - inspector.view = create_view(ThingView, 'GET', create_request('/test/')) - self.assertDictEqual( - inspector.get_operation('/test/', 'GET'), - { - 'operationId': 'ListThings', - 'deprecated': True, - 'description': '', - 'parameters': [], - 'responses': { - '410': { - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'detail': { - 'type': 'string', - 'const': 'This endpoint has been deprecated.', - }, - }, - 'example': { - 'detail': 'This endpoint has been deprecated.', - }, - }, - } - }, - 'description': 'Deprecation message', - } - } - } - ) diff --git a/arkindex/project/tests/openapi/test_view.py b/arkindex/project/tests/openapi/test_views.py similarity index 50% rename from arkindex/project/tests/openapi/test_view.py rename to arkindex/project/tests/openapi/test_views.py index cc26bf2ba749721192a205c406fdef733bfa6443..a9e2a4fd4a6507435c56a8b46ad773284df95940 100644 --- a/arkindex/project/tests/openapi/test_view.py +++ b/arkindex/project/tests/openapi/test_views.py @@ -12,7 +12,7 @@ class TestSchemaView(FixtureAPITestCase): def test_constant_schema(self): """ Ensure that what a SchemaView outputs is always exactly the same - as what manage.py generateschema outputs. + as what manage.py spectacular outputs. Note that we use the JSON output format instead of the normal YAML because JSON will not allow Python-specific objects, while PyYAML would use YAML tags such as "!!python/object", @@ -20,15 +20,16 @@ class TestSchemaView(FixtureAPITestCase): """ stdout = StringIO() call_command( - 'generateschema', - generator_class='arkindex.project.openapi.SchemaGenerator', + 'spectacular', format='openapi-json', + fail_on_warn=True, + validate=True, stdout=stdout, ) expected_schema = stdout.getvalue().strip() def _test_schema(): - response = self.client.get(reverse('api:openapi-schema') + '?format=openapi-json') + response = self.client.get(reverse('api:openapi-schema') + '?format=json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.content.decode('utf-8'), expected_schema) @@ -39,3 +40,23 @@ class TestSchemaView(FixtureAPITestCase): self.client.force_login(self.superuser) _test_schema() + + def test_old_yaml(self): + """ + Test DRF's `?format=openapi` behaves like Spectacular's `?format=yaml` + """ + old_response = self.client.get(reverse('api:openapi-schema') + '?format=openapi') + new_response = self.client.get(reverse('api:openapi-schema') + '?format=yaml') + self.assertEqual(old_response.status_code, status.HTTP_200_OK) + self.assertEqual(new_response.status_code, status.HTTP_200_OK) + self.assertEqual(old_response.content, new_response.content) + + def test_old_json(self): + """ + Test DRF's `?format=openapi-json` behaves like Spectacular's `?format=json` + """ + old_response = self.client.get(reverse('api:openapi-schema') + '?format=openapi-json') + new_response = self.client.get(reverse('api:openapi-schema') + '?format=json') + self.assertEqual(old_response.status_code, status.HTTP_200_OK) + self.assertEqual(new_response.status_code, status.HTTP_200_OK) + self.assertDictEqual(old_response.json(), new_response.json()) diff --git a/arkindex/project/views.py b/arkindex/project/views.py index 833268b32d223f8b8820e4b207eb9e2e514e7e46..aa81d0dd90b04c90fe902e337231bc835ae497d3 100644 --- a/arkindex/project/views.py +++ b/arkindex/project/views.py @@ -1,5 +1,6 @@ from django.conf import settings from django.views.generic import TemplateView, View +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import permissions from arkindex.dataimport.permissions import IsArtifactAdminOrCreator, IsTaskAdminOrCreator @@ -76,6 +77,13 @@ class PonosTaskUpdate(TaskUpdate): permission_classes = (IsTaskAdminOrCreator, ) +@extend_schema_view( + get=extend_schema( + operation_id='DownloadArtifact', + responses={302: None}, + tags=['ponos'], + ) +) class PonosArtifactDownload(TaskArtifactDownload): """ Allow users with an admin privilege and creators to download an artifact of a dataimport task diff --git a/arkindex/templates/redoc.html b/arkindex/templates/redoc.html index 1501bfdb9ac2ba7bf7f4c34ca3cd84349973f27e..979207a30f4d086a801e36c0502f91afe0aa61c5 100644 --- a/arkindex/templates/redoc.html +++ b/arkindex/templates/redoc.html @@ -16,8 +16,8 @@ </style> </head> <body> - <!-- Add ?format=openapi to make ReDoc's download button get a YAML file, not HTML --> - <redoc spec-url="{% url 'api:openapi-schema' %}?format=openapi"></redoc> + <!-- Add ?format=yaml to make ReDoc's download button get a YAML file, not HTML --> + <redoc spec-url="{% url 'api:openapi-schema' %}?format=yaml"></redoc> <script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script> </body> </html> diff --git a/arkindex/users/api.py b/arkindex/users/api.py index 0661e0982f41f3bac60c014f491ae06a0f14b08d..1d22ab8e6e54c1c4c3b67a5707b621a58d87202c 100644 --- a/arkindex/users/api.py +++ b/arkindex/users/api.py @@ -17,11 +17,14 @@ from django.utils.http import urlsafe_base64_encode from django.views.generic import RedirectView from django_rq.queues import get_queue from django_rq.settings import QUEUES -from rest_framework import status +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer +from rest_framework import serializers, status from rest_framework.exceptions import AuthenticationFailed, NotFound, PermissionDenied, ValidationError from rest_framework.generics import ( CreateAPIView, ListAPIView, + ListCreateAPIView, RetrieveAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, @@ -36,7 +39,7 @@ from arkindex.dataimport.models import Worker from arkindex.documents.models import Corpus from arkindex.project.mixins import ACLMixin from arkindex.project.permissions import IsAuthenticatedOrReadOnly, IsVerified -from arkindex.users.models import Group, OAuthStatus, Right, Role, Scope, User, UserScope +from arkindex.users.models import Group, OAuthCredentials, OAuthStatus, Right, Role, Scope, User, UserScope from arkindex.users.providers import get_provider, oauth_providers from arkindex.users.serializers import ( EmailLoginSerializer, @@ -102,6 +105,7 @@ class CredentialsRetrieve(RetrieveDestroyAPIView): openapi_overrides = { 'tags': ['oauth'], } + queryset = OAuthCredentials.objects.none() def get_queryset(self): return self.request.user.credentials.order_by('id') @@ -142,11 +146,12 @@ class UserRetrieve(RetrieveUpdateDestroyAPIView): class UserCreate(CreateAPIView): + """ + Register as a new user. + """ serializer_class = NewUserSerializer openapi_overrides = { 'operationId': 'Register', - 'description': 'Register as a new user.', - 'security': [], 'tags': ['users'], } @@ -194,7 +199,6 @@ class UserEmailLogin(CreateAPIView): serializer_class = EmailLoginSerializer openapi_overrides = { 'operationId': 'Login', - 'security': [], 'tags': ['users'], } @@ -225,32 +229,24 @@ class UserEmailVerification(APIView): Verify a user's email address """ - openapi_overrides = { - 'operationId': 'VerifyEmail', - 'tags': ['users'], - 'parameters': [ - { - 'name': 'email', - 'in': 'query', - 'description': 'E-mail to verify', - 'required': True, - 'schema': { - 'type': 'string', - 'format': 'email', - }, - }, - { - 'name': 'token', - 'in': 'query', - 'description': 'Verification token', - 'required': True, - 'schema': { - 'type': 'string', - }, - }, + @extend_schema( + operation_id='VerifyEmail', + tags=['users'], + parameters=[ + OpenApiParameter( + 'email', + description='E-mail to verify', + type=OpenApiTypes.EMAIL, + required=True, + ), + OpenApiParameter( + 'token', + description='Verification token', + required=True, + ) ], - } - + responses={302: serializers.Serializer} + ) def get(self, *args, **kwargs): if not all(arg in self.request.GET for arg in ('email', 'token')): raise ValidationError @@ -306,7 +302,6 @@ class PasswordReset(CreateAPIView): serializer_class = PasswordResetSerializer openapi_overrides = { 'operationId': 'ResetPassword', - 'security': [], 'tags': ['users'], } @@ -344,7 +339,6 @@ class PasswordResetConfirm(CreateAPIView): serializer_class = PasswordResetConfirmSerializer openapi_overrides = { 'operationId': 'PasswordResetConfirm', - 'security': [], 'tags': ['users'], } @@ -369,11 +363,24 @@ class OAuthSignIn(APIView): Start the OAuth authentication code flow for a given provider """ permission_classes = (IsVerified, ) - openapi_overrides = { - 'operationId': 'StartOAuthSignIn', - 'tags': ['oauth'], - } - + queryset = OAuthCredentials.objects.none() + + @extend_schema( + operation_id='StartOAuthSignIn', + tags=['oauth'], + parameters=[ + OpenApiParameter( + 'provider', + location=OpenApiParameter.PATH, + required=True, + description='Provider name', + ) + ], + responses=inline_serializer( + name='OAuthSignInResponse', + fields={'url': serializers.URLField()} + ) + ) def get(self, *args, **kwargs): if 'provider' not in kwargs: raise ValidationError('Missing provider') @@ -409,6 +416,7 @@ class OAuthRetry(RetrieveAPIView): 'operationId': 'RetryOAuthCredentials', 'tags': ['oauth'], } + queryset = OAuthCredentials.objects.none() def get_queryset(self): return self.request.user.credentials @@ -458,6 +466,26 @@ class OAuthCallback(UserPassesTestMixin, RedirectView): return super().get(request) +class GroupsList(ListCreateAPIView): + """ + List existing groups either if they are public or the request user is a member. + A POST request allow to create a new group. + """ + serializer_class = GroupSerializer + permission_classes = (IsVerified, ) + openapi_overrides = { + 'tags': ['users'], + } + + def get_queryset(self): + # List public groups and ones to which user belongs + return Group.objects \ + .annotate(members_count=Count('users')) \ + .filter(Q(public=True) | Q(users__in=[self.request.user])) \ + .order_by('name', 'id') + + +@extend_schema(tags=['jobs']) class JobList(ListAPIView): """ List asynchronous jobs linked to the current user. @@ -475,6 +503,16 @@ class JobList(ListAPIView): return self.request.user.get_rq_jobs() +@extend_schema( + tags=['jobs'], + parameters=[ + OpenApiParameter( + 'id', + location=OpenApiParameter.PATH, + type=OpenApiTypes.UUID, + ) + ] +) class JobRetrieve(RetrieveDestroyAPIView): """ Retrieve a single job by ID. @@ -501,27 +539,26 @@ class JobRetrieve(RetrieveDestroyAPIView): instance.delete() +@extend_schema(tags=['users']) class GroupsCreate(CreateAPIView): """ Create a new group. The request user will be added as a member of this group. """ serializer_class = GroupSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'tags': ['users'], - } + # For OpenAPI type discovery + queryset = Group.objects.none() +@extend_schema(tags=['users']) class GroupDetails(RetrieveUpdateDestroyAPIView): """ Retrieve details about a specific group """ serializer_class = GroupSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'security': [], - 'tags': ['users'], - } + # For OpenAPI type discovery + queryset = Group.objects.none() def get_membership(self, group): try: @@ -568,16 +605,13 @@ class GroupDetails(RetrieveUpdateDestroyAPIView): ) +@extend_schema(tags=['users']) class UserMemberships(ListAPIView): """ List groups to which the request user belongs with its privileges on each group """ serializer_class = MemberGroupSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'security': [], - 'tags': ['users'], - } def get_queryset(self): @@ -606,15 +640,13 @@ class UserMemberships(ListAPIView): .order_by('group_target__name', 'group_target__id') +@extend_schema(tags=['users']) class MembershipDetails(RetrieveUpdateDestroyAPIView): """ Retrieve details of a generic membership """ serializer_class = MembershipSerializer permission_classes = (IsVerified, ) - openapi_overrides = { - 'tags': ['users'], - } # Perform access checks with the permissions queryset = Right.objects.select_related('content_type').all() @@ -651,6 +683,26 @@ class MembershipDetails(RetrieveUpdateDestroyAPIView): return super().perform_destroy(instance) +MEMBERSHIPS_TYPE_FILTERS = ['user', 'group'] + + +@extend_schema( + parameters=[ + OpenApiParameter( + content.name, + type=OpenApiTypes.UUID, + description=f'List user members for a {content}', + ) + for content in RightContent + ] + [ + OpenApiParameter( + 'type', + description='Filter memberships by owner type', + enum=MEMBERSHIPS_TYPE_FILTERS, + ) + ], + tags=['users'] +) class GenericMembershipsList(ACLMixin, ListAPIView): """ List memberships for a specific content with their privileges. @@ -658,33 +710,6 @@ class GenericMembershipsList(ACLMixin, ListAPIView): """ permission_classes = (IsVerified, ) serializer_class = GenericMembershipSerializer - type_filters = ['user', 'group'] - openapi_overrides = { - 'tags': ['users'], - 'parameters': [ - { - 'name': content.name, - 'in': 'query', - 'description': f'List user members for a {content}', - 'required': False, - 'schema': { - 'type': 'string', - 'format': 'uuid', - } - } - for content in RightContent - ] + [ - { - 'name': 'type', - 'in': 'query', - 'description': 'Filter memberships by owner type', - 'required': False, - 'schema': { - 'enum': type_filters - } - } - ] - } def get_content_object(self): """ @@ -740,19 +765,17 @@ class GenericMembershipsList(ACLMixin, ListAPIView): # Handle a simple owner type filter type_filter = self.request.query_params.get('type') - if type_filter and type_filter in self.type_filters: + if type_filter and type_filter in MEMBERSHIPS_TYPE_FILTERS: type_filter = {f'{type_filter}__isnull': False} qs = qs.filter(**type_filter) return qs +@extend_schema(tags=['users']) class GenericMembershipCreate(CreateAPIView): """ Create a new generic membership. """ permission_classes = (IsVerified, ) serializer_class = MembershipCreateSerializer - openapi_overrides = { - 'tags': ['users'], - } diff --git a/arkindex/users/serializers.py b/arkindex/users/serializers.py index da1f1e7d7c69b59fd67dd4e480dba6b2d81083fa..e54cdc6cfc25d34751befc14d5ecfa42bf705905 100644 --- a/arkindex/users/serializers.py +++ b/arkindex/users/serializers.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import transaction from django.utils.http import urlsafe_base64_decode +from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers from arkindex.dataimport.models import Worker @@ -104,9 +105,17 @@ class UserSerializer(SimpleUserSerializer): 'transkribus_email': {'read_only': True}, } + @extend_schema_field(inline_serializer( + name='Features', + fields={ + name: serializers.BooleanField() + for name in settings.ARKINDEX_FEATURES.keys() + } + )) def get_features(self, *args, **kwargs): return settings.ARKINDEX_FEATURES + @extend_schema_field(serializers.EmailField(allow_null=True)) def get_transkribus_import_email(self, *args, **kwargs): return settings.TRANSKRIBUS_EMAIL @@ -234,7 +243,7 @@ class JobSerializer(serializers.Serializer): started_at = serializers.DateTimeField(read_only=True, allow_null=True) ended_at = serializers.DateTimeField(read_only=True, allow_null=True) - def get_status(self, instance): + def get_status(self, instance) -> str: """ Avoid causing more Redis queries to fetch a job's current status Note that a job status is part of a JobStatus enum, diff --git a/ci/openapi.sh b/ci/openapi.sh index 4dafd1f39b627fcdbc1cff571bf5496aeebd0565..ae808d468093274687e1e2d4ec33bb8004a6ae06 100755 --- a/ci/openapi.sh +++ b/ci/openapi.sh @@ -1,4 +1,4 @@ #!/bin/sh -e mkdir -p output pip install -e . -arkindex/manage.py generateschema --generator_class arkindex.project.openapi.SchemaGenerator > output/schema.yml +arkindex/manage.py spectacular --fail-on-warn --validate > output/schema.yml diff --git a/requirements.txt b/requirements.txt index 06b1e05141177631016f4ca98c6ae736300c92ad..23eaefc7a40703b2443c11dac739d533353dc2d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ django-enumfields==2.0.0 django-redis==4.12.1 django-rq==2.4.0 djangorestframework==3.11.1 +drf-spectacular==0.12.0 elasticsearch-dsl>=6.0.0,<7.0.0 gitpython==3.1.11 python-gitlab==1.7.0