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