From 7389bda4f005070506121a0640f1d49c8f701216 Mon Sep 17 00:00:00 2001
From: Erwan Rouchet <rouchet@teklia.com>
Date: Fri, 5 Apr 2019 08:55:28 +0000
Subject: [PATCH] OpenAPI client

---
 .gitignore                              |   2 +
 .gitlab-ci.yml                          |  18 +
 Makefile                                |   8 +-
 arkindex/dataimport/api.py              |  10 +-
 arkindex/dataimport/serializers.py      |  14 +
 arkindex/dataimport/tests/test_repos.py |  51 ++-
 arkindex/documents/serializers/ml.py    |   9 +-
 arkindex/users/api.py                   |   3 +-
 arkindex/users/serializers.py           |   9 +
 openapi/Dockerfile                      |   9 +
 openapi/patch.py                        | 132 +++++++
 openapi/patch.yml                       | 439 ++++++++++++++++++++++++
 openapi/requirements.txt                |   3 +
 openapi/run.sh                          |   3 +
 requirements.txt                        |   2 +-
 15 files changed, 694 insertions(+), 18 deletions(-)
 create mode 100644 openapi/Dockerfile
 create mode 100755 openapi/patch.py
 create mode 100644 openapi/patch.yml
 create mode 100644 openapi/requirements.txt
 create mode 100755 openapi/run.sh

diff --git a/.gitignore b/.gitignore
index 4caa39dc02..3338e08b48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ arkindex/iiif-users/
 .coverage
 htmlcov
 ponos
+openapi/*.yml
+!openapi/patch.yml
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e4efdda93a..c0b978413b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,6 +1,7 @@
 image: registry.gitlab.com/arkindex/backend:base-0.9.3
 stages:
   - test
+  - build
 
 cache:
   paths:
@@ -41,3 +42,20 @@ backend-lint:
 
   script:
     - flake8
+
+backend-openapi:
+  stage: build
+  image: registry.gitlab.com/arkindex/backend/openapi:latest
+
+  before_script: []
+  script:
+    - mkdir -p output
+    - arkindex/manage.py generateschema > output/original.yml
+    - openapi/patch.py openapi/patch.yml output/original.yml > output/schema.yml
+
+  variables:
+    PONOS_DATA_DIR: /tmp
+
+  artifacts:
+    paths:
+      - output/
diff --git a/Makefile b/Makefile
index 9b14aff8b6..96d9bec742 100644
--- a/Makefile
+++ b/Makefile
@@ -8,6 +8,7 @@ VERSION=$(shell git rev-parse --short HEAD)
 TAG_APP=arkindex-app
 TAG_BASE=arkindex-base
 TAG_SHELL=arkindex-shell
+TAG_OPENAPI=arkindex-openapi
 .PHONY: build base
 
 all: clean build
@@ -29,11 +30,15 @@ build:
 build-shell:
 	docker build -t $(TAG_SHELL):$(VERSION) -t $(TAG_SHELL):latest $(ROOT_DIR)/shell
 
+build-openapi:
+	docker build -t $(TAG_OPENAPI):$(VERSION) -t $(TAG_OPENAPI):latest $(ROOT_DIR)/openapi
+
 publish-version: require-docker-auth
 	[ -f $(ROOT_DIR)/arkindex/project/local_settings.py ] && mv $(ROOT_DIR)/arkindex/project/local_settings.py $(ROOT_DIR)/arkindex/project/local_settings.py.bak || true
 	$(MAKE) build TAG_APP=registry.gitlab.com/arkindex/backend
 	$(MAKE) build-shell TAG_SHELL=registry.gitlab.com/arkindex/backend/shell
-	docker push registry.gitlab.com/arkindex/backend:$(VERSION)
+	$(MAKE) build-openapi TAG_OPENAPI=registry.gitlab.com/arkindex/backend/openapi
+	docker push registry.gitlab.com/arkindex/backend:$(VERSION) registry.gitlab.com/arkindex/backend/shell:$(VERSION) registry.gitlab.com/arkindex/backend/openapi:$(VERSION)
 	[ -f $(ROOT_DIR)/arkindex/project/local_settings.py.bak ] && mv $(ROOT_DIR)/arkindex/project/local_settings.py.bak $(ROOT_DIR)/arkindex/project/local_settings.py || true
 
 latest:
@@ -44,6 +49,7 @@ release:
 	$(MAKE) publish-version VERSION=$(version)
 	docker push registry.gitlab.com/arkindex/backend:latest
 	docker push registry.gitlab.com/arkindex/backend/shell:latest
+	docker push registry.gitlab.com/arkindex/backend/openapi:latest
 	git tag $(version)
 
 tunnel:
diff --git a/arkindex/dataimport/api.py b/arkindex/dataimport/api.py
index 5b339b4876..1bb1c1893b 100644
--- a/arkindex/dataimport/api.py
+++ b/arkindex/dataimport/api.py
@@ -20,7 +20,8 @@ from arkindex.dataimport.models import \
 from arkindex.dataimport.serializers import (
     DataImportLightSerializer, DataImportSerializer, DataImportFromFilesSerializer,
     DataImportFailureSerializer, DataFileSerializer,
-    RepositorySerializer, ExternalRepositorySerializer, EventSerializer, MLToolSerializer,
+    RepositorySerializer, RepositoryStartImportSerializer,
+    ExternalRepositorySerializer, EventSerializer, MLToolSerializer,
 )
 from arkindex.users.models import OAuthCredentials
 from arkindex_common.ml_tool import MLTool
@@ -364,13 +365,14 @@ class RepositoryRetrieve(CorpusACLMixin, RetrieveDestroyAPIView):
 
 class RepositoryStartImport(RetrieveAPIView):
     permission_classes = (IsAdminUser, )
+    serializer_class = RepositoryStartImportSerializer
 
     def get_queryset(self):
         return Repository.objects.filter(
             corpus__in=Corpus.objects.writable(self.request.user),
         )
 
-    def get(self, request, *args, **kwargs):
+    def retrieve(self, request, *args, **kwargs):
         repo = self.get_object()
 
         if not repo.enabled:
@@ -380,7 +382,9 @@ class RepositoryStartImport(RetrieveAPIView):
             credentials=repo.credentials,
         ).get_or_create_latest_revision(repo)
 
-        return Response(data={'import_id': str(rev.start_import().id)})
+        di = rev.start_import()
+
+        return Response(self.get_serializer(di).data)
 
 
 class ElementHistory(ListAPIView):
diff --git a/arkindex/dataimport/serializers.py b/arkindex/dataimport/serializers.py
index 072883902c..0d4d3cc6ff 100644
--- a/arkindex/dataimport/serializers.py
+++ b/arkindex/dataimport/serializers.py
@@ -222,6 +222,20 @@ class RepositorySerializer(serializers.ModelSerializer):
         }
 
 
+class RepositoryStartImportSerializer(serializers.ModelSerializer):
+    """
+    A serializer used by the RepositoryStartImport endpoint to return a DataImport ID.
+    This serializer is required to get the OpenAPI schema generation to work.
+    """
+
+    import_id = serializers.UUIDField(source='id')
+
+    class Meta:
+        model = DataImport
+        fields = ('import_id',)
+        read_only_fields = ('import_id',)
+
+
 class ExternalRepositorySerializer(serializers.Serializer):
     """
     Serialize a Git repository from an external API
diff --git a/arkindex/dataimport/tests/test_repos.py b/arkindex/dataimport/tests/test_repos.py
index 0d2b6d5384..9aa44f4f22 100644
--- a/arkindex/dataimport/tests/test_repos.py
+++ b/arkindex/dataimport/tests/test_repos.py
@@ -1,3 +1,6 @@
+from django.urls import reverse
+from rest_framework import status
+from unittest.mock import patch
 from arkindex.project.tests import FixtureTestCase
 from arkindex.dataimport.models import Repository, DataImport, DataImportMode
 from ponos.models import Workflow
@@ -6,12 +9,11 @@ from rest_framework.exceptions import ValidationError
 
 class TestRepositories(FixtureTestCase):
 
-    @classmethod
-    def setUpTestData(cls):
-        super().setUpTestData()
-        cls.creds = cls.user.credentials.get()
-        cls.repo = cls.creds.repos.get()
-        cls.rev = cls.repo.revisions.get()
+    def setUp(self):
+        super().setUp()
+        self.creds = self.user.credentials.get()
+        self.repo = self.creds.repos.get()
+        self.rev = self.repo.revisions.get()
 
     def test_delete_credentials_null(self):
         """
@@ -21,13 +23,13 @@ class TestRepositories(FixtureTestCase):
         self.assertTrue(Repository.objects.filter(url='http://gitlab/repo').exists())
         self.repo.refresh_from_db()
         self.assertTrue(self.repo.revisions.exists())
-        self.creds.save()  # Put them back
 
     def test_no_credentials_no_import(self):
         """
         Check Repository imports do not start without credentials
         """
-        self.creds.delete()
+        self.repo.credentials = None
+        self.repo.save()
         self.assertEqual(Workflow.objects.count(), 0)
 
         di = DataImport.objects.create(
@@ -44,4 +46,35 @@ class TestRepositories(FixtureTestCase):
             di.retry()
 
         self.assertEqual(Workflow.objects.count(), 0)
-        self.creds.save()
+
+    @patch('arkindex.dataimport.providers.GitLabProvider.get_or_create_latest_revision')
+    def test_start(self, gitlab_rev_mock):
+        gitlab_rev_mock.return_value = self.rev, False
+        self.client.force_login(self.superuser)
+        self.assertEqual(Workflow.objects.count(), 0)
+
+        resp = self.client.get(reverse('api:repository-import', kwargs={'pk': str(self.repo.id)}))
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        data = resp.json()
+
+        di = DataImport.objects.get(id=data['import_id'])
+        self.assertEqual(di.corpus, self.corpus)
+        self.assertEqual(di.mode, DataImportMode.Repository)
+        self.assertEqual(di.creator, self.user)
+        self.assertEqual(di.revision, self.rev)
+
+        self.assertEqual(Workflow.objects.count(), 1)
+
+    def test_start_no_credentials(self):
+        """
+        Test the repository start endpoint fails without credentials
+        """
+        self.client.force_login(self.superuser)
+        self.repo.credentials = None
+        self.repo.save()
+        self.assertFalse(self.repo.enabled)
+        self.assertEqual(Workflow.objects.count(), 0)
+
+        resp = self.client.get(reverse('api:repository-import', kwargs={'pk': str(self.repo.id)}))
+        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(Workflow.objects.count(), 0)
diff --git a/arkindex/documents/serializers/ml.py b/arkindex/documents/serializers/ml.py
index f8d1b0a568..56f0b18ccb 100644
--- a/arkindex/documents/serializers/ml.py
+++ b/arkindex/documents/serializers/ml.py
@@ -82,7 +82,8 @@ class TranscriptionCreateSerializer(serializers.Serializer):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        assert 'request' in self.context, 'An API request is required to initialize this serializer'
+        if not self.context.get('request'):  # May be None when generating an OpenAPI schema or using from a REPL
+            return
         self.fields['element'].queryset = Element.objects.filter(
             corpus__in=Corpus.objects.writable(self.context['request'].user),
         )
@@ -118,7 +119,8 @@ class TranscriptionsSerializer(serializers.Serializer):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        assert 'request' in self.context, 'An API request is required to initialize this serializer'
+        if not self.context.get('request'):  # May be None when generating an OpenAPI schema or using from a REPL
+            return
         self.fields['parent'].queryset = Element.objects.filter(
             corpus__in=Corpus.objects.writable(self.context['request'].user),
         )
@@ -143,7 +145,8 @@ class ClassificationsSerializer(serializers.Serializer):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        assert 'request' in self.context, 'An API request is required to initialize this serializer'
+        if not self.context.get('request'):  # May be None when generating an OpenAPI schema or using from a REPL
+            return
         self.fields['parent'].queryset = Page.objects.filter(
             corpus__in=Corpus.objects.writable(self.context['request'].user),
         )
diff --git a/arkindex/users/api.py b/arkindex/users/api.py
index 0a55e9c24c..c0742ac74c 100644
--- a/arkindex/users/api.py
+++ b/arkindex/users/api.py
@@ -18,7 +18,7 @@ from arkindex.documents.models import Corpus, Element, ElementType
 from arkindex.users.providers import oauth_providers, get_provider
 from arkindex.users.models import User, OAuthStatus
 from arkindex.users.serializers import (
-    OAuthCredentialsSerializer, OAuthProviderClassSerializer,
+    OAuthCredentialsSerializer, OAuthProviderClassSerializer, OAuthRetrySerializer,
     UserSerializer, NewDemoUserSerializer, EmailLoginSerializer,
     PasswordResetSerializer, PasswordResetConfirmSerializer,
 )
@@ -242,6 +242,7 @@ class OAuthRetry(RetrieveAPIView):
     Restart an OAuth authentication workflow for existing credentials
     """
     permission_classes = (IsVerified, )
+    serializer_class = OAuthRetrySerializer
 
     def check_permissions(self, request):
         # Will raise REST framework exceptions for denied requests
diff --git a/arkindex/users/serializers.py b/arkindex/users/serializers.py
index 27b8b94b83..553c13ec9d 100644
--- a/arkindex/users/serializers.py
+++ b/arkindex/users/serializers.py
@@ -29,6 +29,15 @@ class OAuthProviderClassSerializer(serializers.Serializer):
     default_url = serializers.URLField(source='url')
 
 
+class OAuthRetrySerializer(serializers.Serializer):
+    """
+    A serializer used by the OAuthRetry endpoint to return an authorization URL.
+    Required to get the OpenAPI schema generation to work.
+    """
+
+    url = serializers.URLField()
+
+
 class UserSerializer(serializers.ModelSerializer):
 
     class Meta:
diff --git a/openapi/Dockerfile b/openapi/Dockerfile
new file mode 100644
index 0000000000..1290a6b66f
--- /dev/null
+++ b/openapi/Dockerfile
@@ -0,0 +1,9 @@
+# FROM registry.gitlab.com/arkindex/backend:latest
+FROM arkindex-app
+
+RUN pip uninstall -y djangorestframework
+COPY ["patch.py", "run.sh", "requirements.txt", "patch.yml", "/"]
+RUN pip install -r /requirements.txt && rm /requirements.txt
+
+ENTRYPOINT ["/bin/sh", "-c"]
+CMD ["/run.sh"]
diff --git a/openapi/patch.py b/openapi/patch.py
new file mode 100755
index 0000000000..bf855c6362
--- /dev/null
+++ b/openapi/patch.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+import argparse
+import apistar
+import os.path
+import pkg_resources
+import sys
+import yaml
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+def get_args():
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        'patch',
+        help='File describing patches to make on the schema',
+        type=argparse.FileType('r'),
+    )
+    parser.add_argument(
+        'original',
+        help='Generated OpenAPI schema',
+        type=argparse.FileType('r'),
+        nargs='?',
+        default=sys.stdin,
+    )
+    parser.add_argument(
+        'output',
+        help='Destination file',
+        type=argparse.FileType('w'),
+        nargs='?',
+        default=sys.stdout,
+    )
+    args = vars(parser.parse_args())
+
+    # Load YAML files
+    args['original'] = yaml.load(args['original'])
+    args['patch'] = yaml.load(args['patch'])
+    return args
+
+
+def update_schema(schema, patches):
+    """
+    Perform updates on an OpenAPI schema using another YAML file describing patches.
+    The update is not recursive; any root key will overwrite the schema's original data.
+    For paths, each operation has its keys updated; the whole operation is not overwritten.
+    """
+    # Update each method separately
+    paths = patches.pop('paths', {})
+    for path, methods in paths.items():
+        if path not in schema['paths']:
+            print('Creating path {}'.format(path), file=sys.stderr)
+            schema['paths'][path] = {}
+
+        for method, operation in methods.items():
+            print(
+                '{} method {} on path {}'.format(
+                    'Updating' if method in schema['paths'][path] else 'Creating',
+                    method,
+                    path,
+                ),
+                file=sys.stderr,
+            )
+            schema['paths'][path].setdefault(method, {})
+            schema['paths'][path][method].update(operation)
+
+    # Update other root keys
+    for k, v in patches.items():
+        print('Patching {}'.format(k), file=sys.stderr)
+        schema[k] = v
+
+    # Set the API version to the Arkindex package version
+    try:
+        # Try with the VERSION file
+        with open(os.path.join(BASE_DIR, '..', 'VERSION')) as f:
+            ark_version = f.read().strip()
+    except FileNotFoundError:
+        # Fall back to the pip package version
+        ark_version = pkg_resources.get_distribution('arkindex').version
+
+    schema.setdefault('info', {})
+    schema['info']['version'] = ark_version
+
+    # Add x-name property on request body objects to let apistar register the parameters
+    for path, methods in schema['paths'].items():
+        for method, operation in methods.items():
+            if 'requestBody' not in operation:
+                continue
+            if not operation['requestBody']['content']['application/json']['schema']:
+                # Ignore empty schemas
+                continue
+
+            # Make sure we do not duplicate an existing parameter name
+            assert not any(param['name'] == 'body' for param in operation['parameters']), \
+                'Operation already has a body parameter'
+
+            print('Adding x-name to {} on {}'.format(method, path), file=sys.stderr)
+            operation['requestBody']['x-name'] = 'body'
+
+    return schema
+
+
+def print_endpoints(schema):
+    """
+    Output a list of all endpoints with their operation IDs and security settings to stderr
+    """
+    for path, methods in schema['paths'].items():
+        print(path, file=sys.stderr)
+        for method, operation in methods.items():
+            security = operation.get('security')
+            auth = 'Custom'
+            if security is None:
+                auth = 'Default'
+            elif security == []:
+                auth = 'No'
+
+            print('  {}: {} - {} authentication'.format(
+                method,
+                operation.get('operationId', 'No operation ID!'),
+                auth,
+            ), file=sys.stderr)
+
+
+def main():
+    args = get_args()
+    schema = update_schema(args['original'], args['patch'])
+    print_endpoints(schema)
+    apistar.validate(schema)
+    yaml.dump(schema, args['output'], default_flow_style=False)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/openapi/patch.yml b/openapi/patch.yml
new file mode 100644
index 0000000000..f97d5db790
--- /dev/null
+++ b/openapi/patch.yml
@@ -0,0 +1,439 @@
+info:
+  title: Arkindex API
+  contact:
+    name: Teklia
+    url: https://www.teklia.com/
+    email: paris@teklia.com
+components:
+  securitySchemes:
+    sessionAuth:
+      in: cookie
+      name: arkindex.auth
+      type: apiKey
+    tokenAuth:
+      scheme: Token
+      type: http
+security:
+- tokenAuth: []
+- sessionAuth: []
+servers:
+  - description: Arkindex
+    url: https://arkindex.teklia.com
+  - description: Arkindex preproduction
+    url: https://arkindex.dev.teklia.com
+tags:
+  - name: corpora
+  - name: elements
+  - name: search
+  - name: oauth
+  - name: imports
+  - name: files
+  - name: ponos
+  - name: iiif
+    description: IIIF manifests, annotation lists and services
+  - name: ml
+    description: Machine Learning tools and results
+paths:
+  /api/v1/act/{id}/:
+    get:
+      description: Retrieve an act
+      security: []
+      tags:
+        - elements
+  /api/v1/acts/:
+    get:
+      operationId: SearchActs
+      description: >-
+        Get a list of acts with their parent registers or volumes, the total
+        number of transcriptions found in the act, and a few (not all) of the
+        transcriptions found inside of each act, with their source, type,
+        zone and image, for a given search query.
+      security: []
+      tags:
+        - search
+  /api/v1/classification/bulk/:
+    post:
+      description: >-
+        Create multiple classifications at once on the same element with
+        the same classifier.
+      tags:
+        - ml
+  /api/v1/corpus/:
+    get:
+      description: List corpora with their access rights
+      security: []
+      tags:
+        - corpora
+    post:
+      description: Create a new corpus
+      tags:
+        - corpora
+  /api/v1/corpus/{id}/:
+    get:
+      description: Retrieve a single corpus
+      security: []
+      tags:
+        - corpora
+    put:
+      description: Update a corpus
+      tags:
+        - corpora
+    patch:
+      description: Partially update a corpus
+      tags:
+        - corpora
+    delete:
+      description: >-
+        Delete a corpus. Requires the "admin" right on the corpus.
+
+        Warning: This operation might not work on corpora with a large amount
+        of elements, as the deletion process will take more than 30 seconds.
+      tags:
+        - corpora
+  /api/v1/corpus/{id}/pages/:
+    get:
+      operationId: ListCorpusPages
+      description: List all pages in all volumes of a corpus
+      security: []
+      tags:
+        - elements
+  /api/v1/element/{id}/:
+    get:
+      description: Retrieve detailed information on a single element
+      security: []
+      tags:
+        - elements
+    patch:
+      description: Rename an element
+      tags:
+        - elements
+    put:
+      description: Rename an element
+      tags:
+        - elements
+  /api/v1/element/{id}/history/:
+    get:
+      description: List an element's update history
+      security: []
+      tags:
+        - elements
+  /api/v1/elements/:
+    get:
+      operationId: ListElements
+      description: List all elements, filtered by type
+      security: []
+      tags:
+        - elements
+  /api/v1/elements/{id}/:
+    get:
+      operationId: ListRelatedElements
+      description: List all parents and children of a single element
+      security: []
+      tags:
+        - elements
+  /api/v1/elements/{id}/pages/:
+    get:
+      operationId: ListElementPages
+      description: Detailed list of all children pages of an element
+      security: []
+      tags:
+        - elements
+  /api/v1/elements/{id}/surfaces/:
+    get:
+      operationId: ListElementSurfaces
+      description: Detailed list of all children surfaces of an element
+      security: []
+      tags:
+        - elements
+  /api/v1/imports/:
+    get:
+      operationId: ListDataImports
+      description: List all data imports
+      tags:
+        - imports
+  /api/v1/imports/demo/{id}/:
+    post:
+      description: Run a data import with reduced access for demo users
+      security: []
+      tags:
+        - imports
+  /api/v1/imports/file/{id}/:
+    get:
+      description: Get an uploaded file's metadata
+      tags:
+        - files
+    patch:
+      description: Rename an uploaded file
+      tags:
+        - files
+    put:
+      description: Rename an uploaded file
+      tags:
+        - files
+    delete:
+      description: Delete an uploaded file
+      tags:
+        - files
+  /api/v1/imports/files/{id}/:
+    get:
+      operationId: ListDataFiles
+      description: List uploaded files in a corpus
+      tags:
+        - files
+  /api/v1/imports/fromfiles/:
+    post:
+      description: Start a data import from one or more uploaded files
+      tags:
+        - files
+  /api/v1/imports/hook/{id}/:
+    post:
+      operationId: GitPushHook
+      description: >-
+        This endpoint is intended as a webhook for Git repository hosting applications like GitLab.
+      security: []
+      tags:
+        - repos
+  /api/v1/imports/mltools/:
+    get:
+      description: List available machine learning tools
+      security: []
+      tags:
+        - ml
+  /api/v1/imports/repos/:
+    get:
+      operationId: ListRepositories
+      description: List connected repositories
+      tags:
+        - repos
+  /api/v1/imports/repos/search/{id}/:
+    get:
+      description: >-
+        Search for a repository to connect to.
+
+        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.
+      tags:
+        - repos
+    post:
+      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
+  /api/v1/imports/repos/{id}/:
+    get:
+      description: Get a repository
+      tags:
+        - repos
+    delete:
+      description: Delete a repository
+      tags:
+        - repos
+  /api/v1/imports/repos/{id}/start/:
+    get:
+      operationId: StartRepositoryImport
+      description: Start a data import on the latest revision on a repository
+      tags:
+        - repos
+  /api/v1/imports/upload/{id}/:
+    post:
+      operationId: UploadDataFile
+      description: Upload a file to a corpus
+      tags:
+        - files
+  /api/v1/imports/{id}/:
+    get:
+      description: Retrieve a data import
+      tags:
+        - imports
+    delete:
+      description: Delete a data import. Cannot be used on currently running data imports.
+      tags:
+        - imports
+  /api/v1/imports/{id}/failures/:
+    get:
+      description: List a data import's XML errors
+      tags:
+        - imports
+  /api/v1/imports/{id}/retry/:
+    post:
+      operationId: RetryDataImport
+      description: Retry a data import. Can only be used on imports with Error or Failed states.
+      tags:
+        - imports
+  /api/v1/manifest/{id}/act/:
+    get:
+      operationId: RetrieveActManifest
+      description: Retrieve a IIIF manifest for an act
+      security: []
+      tags:
+        - iiif
+  /api/v1/manifest/{id}/acts/:
+    get:
+      operationId: RetrieveActAnnotationList
+      description: Retrieve an IIIF annotation list for a volume's surfaces and acts
+      security: []
+      tags:
+        - iiif
+  /api/v1/manifest/{id}/pages/:
+    get:
+      description: Retrieve an IIIF manifest for a volume.
+      security: []
+      tags:
+        - iiif
+  /api/v1/manifest/{id}/search/:
+    get:
+      operationId: SearchTranscriptionsAnnotationList
+      description: >-
+        Search for transcriptions on a volume manifest.
+        This endpoint is intended as a IIIF Search API 2.0 service.
+      security: []
+      tags:
+        - iiif
+  /api/v1/manifest/{id}/surface/:
+    get:
+      description: Retrieve an IIIF annotation list for a single surface
+      security: []
+      tags:
+        - iiif
+  /api/v1/manifest/{id}/transcriptions/:
+    get:
+      operationId: RetrieveTranscriptionAnnotationList
+      description: Retrieve an IIIF annotation list for a volume's transcriptions
+      security: []
+      tags:
+        - iiif
+  /api/v1/oauth/credentials/:
+    get:
+      description: List all OAuth credentials for the authenticated user
+      tags:
+        - oauth
+  /api/v1/oauth/credentials/{id}/:
+    get:
+      description: Retrieve OAuth credentials
+      tags:
+        - oauth
+    delete:
+      description: Delete OAuth credentials. This may disable access to some Git repositories.
+      tags:
+        - oauth
+  /api/v1/oauth/credentials/{id}/retry/:
+    get:
+      operationId: RetryOAuthCredentials
+      description: Retry the OAuth authentication code flow for pending credentials
+      tags:
+        - oauth
+  /api/v1/oauth/providers/:
+    get:
+      operationId: ListOAuthProviders
+      description: List supported OAuth providers
+      tags:
+        - oauth
+  /api/v1/oauth/providers/{provider}/signin/:
+    get:
+      operationId: StartOAuthSignIn
+      description: Start the OAuth authentication code flow for a given provider
+      tags:
+        - oauth
+  /api/v1/page/{id}/:
+    get:
+      description: Retrieve detailed information about a page
+      security: []
+      tags:
+        - elements
+  /api/v1/pages/:
+    get:
+      operationId: SearchPages
+      description: >-
+        Get a list of pages with their parent registers or volumes, the total
+        number of transcriptions found in the page, and a few (not all) of the
+        transcriptions found inside of each page, with their source, type,
+        zone and image, for a given search query.
+      security: []
+      tags:
+        - search
+  /api/v1/surface/{id}/:
+    get:
+      description: Retrieve detailed information about a surface
+      security: []
+      tags:
+        - elements
+  /api/v1/transcription/:
+    post:
+      operationId: CreateTranscription
+      description: Create a single transcription on a page
+      tags:
+        - ml
+  /api/v1/transcription/bulk/:
+    post:
+      description: >-
+        Create multiple transcriptions at once, all linked to the same page
+        and to the same classifier.
+      tags:
+        - ml
+  /api/v1/user/:
+    get:
+      description: Retrieve information about the authenticated user
+      tags:
+        - users
+    patch:
+      description: Update a user's password. This action is not allowed to users without confirmed e-mails.
+      tags:
+        - users
+    put:
+      description: Update a user's password. This action is not allowed to users without confirmed e-mails.
+      tags:
+        - users
+    delete:
+      operationId: Logout
+      description: Log out from the API
+      tags:
+        - users
+  /api/v1/user/login/:
+    post:
+      operationId: Login
+      description: Login using a username and a password
+      security: []
+      tags:
+        - users
+  /api/v1/user/new/:
+    post:
+      operationId: Register
+      description: Register as a demo user
+      security: []
+      tags:
+        - users
+  /api/v1/user/password-reset/:
+    post:
+      operationId: ResetPassword
+      description: Start a password reset flow
+      security: []
+      tags:
+        - users
+  /api/v1/user/password-reset/confirm/:
+    post:
+      operationId: PasswordResetConfirm
+      description: Confirm a password reset using data from the confirmation e-mail
+      security: []
+      tags:
+        - users
+  /ponos/v1/task/{id}/:
+    get:
+      description: Retrieve a Ponos task status
+      security: []
+      tags:
+        - ponos
+  /ponos/v1/task/{id}/log/:
+    get:
+      operationId: RetrieveTaskLog
+      description: Retrieve the full task log as plain text
+      security: []
+      tags:
+        - ponos
+  /ponos/v1/workflow/{id}/:
+    get:
+      description: Retrieve a Ponos workflow status
+      security: []
+      tags:
+        - ponos
diff --git a/openapi/requirements.txt b/openapi/requirements.txt
new file mode 100644
index 0000000000..e9cc0800b3
--- /dev/null
+++ b/openapi/requirements.txt
@@ -0,0 +1,3 @@
+git+https://github.com/encode/django-rest-framework.git@ac64c0a536b0ae21b81d86c3c2a37bc0c70f932e#egg=djangorestframework
+coreapi==2.3.3
+apistar>=0.7.2
diff --git a/openapi/run.sh b/openapi/run.sh
new file mode 100755
index 0000000000..47c4f7391c
--- /dev/null
+++ b/openapi/run.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+PONOS_DATA_DIR=/tmp manage.py generateschema > original.yml
+./patch.py patch.yml original.yml
diff --git a/requirements.txt b/requirements.txt
index 155f512b7a..10dd5e991a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,7 +6,7 @@ certifi==2017.7.27.1
 chardet==3.0.4
 django-cors-headers==2.4.0
 django-enumfields==1.0.0
-djangorestframework==3.7.1
+djangorestframework==3.9.2
 et-xmlfile==1.0.1
 gitpython==2.1.11
 idna==2.6
-- 
GitLab