From 35bdaa61d58c05dbacf071b8e9456c658c44e817 Mon Sep 17 00:00:00 2001 From: Manuel Holtgrewe Date: Mon, 7 Feb 2022 11:38:49 +0100 Subject: [PATCH] Adding REST API versioning (#333) --- HISTORY.rst | 1 + beaconsite/tests/test_views_api.py | 6 --- docs_manual/api_overview.rst | 63 ++++++++++++++++++++++++++ docs_manual/index.rst | 13 ++++++ importer/tests/test_views_api.py | 72 +++++++++++++++++++++--------- importer/views_api.py | 15 +++++++ varfish/api_utils.py | 24 ++++++++++ variants/tests/helpers.py | 16 ++++++- variants/tests/test_views_api.py | 48 ++++++++++++++++++-- variants/views_api.py | 13 ++++-- 10 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 docs_manual/api_overview.rst create mode 100644 varfish/api_utils.py diff --git a/HISTORY.rst b/HISTORY.rst index c021359e3..5181b4ed0 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -91,6 +91,7 @@ Full Change List - Fixing CADD annotation (#319) - Adding mitochondrial inheritance to case phenotype annotation (#325) - Fix issue with variant annotation export (#328) +- Adding REST API versioning to (#333) ------- v0.23.9 diff --git a/beaconsite/tests/test_views_api.py b/beaconsite/tests/test_views_api.py index 1b744b32d..8b26533c0 100644 --- a/beaconsite/tests/test_views_api.py +++ b/beaconsite/tests/test_views_api.py @@ -1,11 +1,5 @@ -import urllib -import datetime - import cattr -from Crypto.PublicKey import RSA from django.shortcuts import reverse -from django.utils import timezone -from projectroles.tests.test_permissions_api import TestProjectAPIPermissionBase from projectroles.tests.test_views_api import TestAPIViewsBase from variants.tests.factories import SmallVariantFactory diff --git a/docs_manual/api_overview.rst b/docs_manual/api_overview.rst new file mode 100644 index 000000000..4b986813c --- /dev/null +++ b/docs_manual/api_overview.rst @@ -0,0 +1,63 @@ +.. _api_overview: + +================= +REST API Overview +================= + +Varfish provides a growing set of REST APIs. +You can find an Python library for accessing the API and a command line interface in `varfish-cli `_. + +.. note:: + This documentation section is under development. + +------------- +Using the API +------------- + +Usage of the REST API is detailed in this section. +Basic knowledge of HTTP APIs is assumed. + +Authentication +============== + +The API supports authentication through Knox authentication tokens as well as logging in using your SODAR username and password. +Tokens are the recommended method for security purposes. + +For token access, first retrieve your token using the **API Tokens** site app on the VarFish web UI. +Note that you can you only see the token once when creating it. + +Add the token in the ``Authorization`` header of your HTTP request as follows: + +.. code-block:: console + + Authorization: token 90c2483172515bc8f6d52fd608e5031db3fcdc06d5a83b24bec1688f39b72bcd + +Versioning +========== + +The SODAR REST API uses accept header versioning. +While specifying the desired API version in your HTTP requests is optional, it is **strongly recommended**. +This ensures you will get the appropriate return data and avoid running into unexpected incompatibility issues. + +To enable versioning, add the ``Accept`` header to your request with the following media type and version syntax. +Replace the version number with your expected version. + +Specific sections of the SODAR API may require their own accept header. +See the exact header requirement in the respective documentation on each section of the API. + +Model Access and Permissions +============================ + +Objects in SODAR API views are accessed through their ``sodar_uuid`` field. + +In the REST API documentation, *"UUID"* refers to the ``sodar_uuid`` field of each model unless otherwise noted. + +For permissions the API uses the same rules which are in effect in the SODAR GUI. +That means you need to have appropriate project access for each operation. + +Return Data +=========== + +The return data for each request will be a JSON document unless otherwise specified. + +If return data is not specified in the documentation of an API view, it will return the appropriate HTTP status code along with an optional ``detail`` JSON field upon a successfully processed request. diff --git a/docs_manual/index.rst b/docs_manual/index.rst index cc0164272..173c3856e 100644 --- a/docs_manual/index.rst +++ b/docs_manual/index.rst @@ -117,6 +117,19 @@ Currently, the main focus is on small/sequence variants called from high-througp sop_supporting sop_filtration +.. raw:: latex + + \part{REST API} + +.. toctree:: + :maxdepth: 1 + :caption: REST API + :name: api-docs + :hidden: + :titlesonly: + + api_overview + .. raw:: latex \part{Developer's Manual} diff --git a/importer/tests/test_views_api.py b/importer/tests/test_views_api.py index df9cfc42f..96a2aa5cf 100644 --- a/importer/tests/test_views_api.py +++ b/importer/tests/test_views_api.py @@ -1,3 +1,6 @@ +from varfish import __version__ as varfish_version +from varfish.api_utils import VARFISH_API_DEFAULT_VERSION, VARFISH_API_MEDIA_TYPE + import os from itertools import chain @@ -5,7 +8,11 @@ from django.urls import reverse from django.forms import model_to_dict -from variants.tests.helpers import ApiViewTestBase +from variants.tests.helpers import ( + ApiViewTestBase, + VARFISH_INVALID_VERSION, + VARFISH_INVALID_MIMETYPE, +) from variants.tests.test_views_api import transmogrify_pedigree from ..models import CaseImportInfo, VariantSetImportInfo, CaseVariantType, BamQcFile, GenotypeFile @@ -16,6 +23,15 @@ GenotypeFileFactory, ) +#: A known invalid MIME type. +INVALID_MIMETYPE = "application/vnd.bihealth.invalid+json" +#: A known invalid version. +INVALID_VERSION = "0.0.0" +#: The known valid MIME type. +VALID_MIMETYPE = "application/vnd.bihealth.varfish+json" +#: A known valid version. +VALID_VERSION = varfish_version + #: The User model to use. User = get_user_model() @@ -84,11 +100,12 @@ def test_create(self): post_data = case_import_info_to_dict(obj, self.project, exclude=("sodar_uuid",)) with self.login(self.user): - response = self.client.post( + response = self.request_knox( reverse( "importer:api-case-import-info-list-create", kwargs={"project": self.project.sodar_uuid}, ), + method="POST", data=post_data, format="json", ) @@ -105,7 +122,7 @@ def test_create(self): def test_retrieve(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-case-import-info-retrieve-update-destroy", kwargs={ @@ -138,7 +155,7 @@ def test_update(self): } with self.login(self.user): - response = self.client.patch( + response = self.request_knox( reverse( "importer:api-case-import-info-retrieve-update-destroy", kwargs={ @@ -146,6 +163,7 @@ def test_update(self): "caseimportinfo": self.case_import_info.sodar_uuid, }, ), + method="PATCH", data=post_data, format="json", ) @@ -166,14 +184,15 @@ def test_update(self): def test_destroy(self): with self.login(self.user): - response = self.client.delete( + response = self.request_knox( reverse( "importer:api-case-import-info-retrieve-update-destroy", kwargs={ "project": self.project.sodar_uuid, "caseimportinfo": self.case_import_info.sodar_uuid, }, - ) + ), + method="DELETE", ) expected = None @@ -204,7 +223,7 @@ def setUp(self): def test_list(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-variant-set-import-info-list-create", kwargs={"caseimportinfo": self.case_import_info.sodar_uuid}, @@ -240,11 +259,12 @@ def test_create(self): ) with self.login(self.user): - response = self.client.post( + response = self.request_knox( reverse( "importer:api-variant-set-import-info-list-create", kwargs={"caseimportinfo": self.case_import_info.sodar_uuid}, ), + method="POST", data=post_data, format="json", ) @@ -259,7 +279,7 @@ def test_create(self): def test_retrieve(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-variant-set-import-info-retrieve-update-destroy", kwargs={ @@ -287,7 +307,7 @@ def test_update(self): } with self.login(self.user): - response = self.client.patch( + response = self.request_knox( reverse( "importer:api-variant-set-import-info-retrieve-update-destroy", kwargs={ @@ -295,6 +315,7 @@ def test_update(self): "variantsetimportinfo": self.variant_set_import_info.sodar_uuid, }, ), + method="PATCH", data=post_data, format="json", ) @@ -315,14 +336,15 @@ def test_update(self): def test_destroy(self): with self.login(self.user): - response = self.client.delete( + response = self.request_knox( reverse( "importer:api-variant-set-import-info-retrieve-update-destroy", kwargs={ "caseimportinfo": self.case_import_info.sodar_uuid, "variantsetimportinfo": self.variant_set_import_info.sodar_uuid, }, - ) + ), + method="DELETE", ) expected = None @@ -349,7 +371,7 @@ def setUp(self): def test_list(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-bam-qc-file-list-create", kwargs={"caseimportinfo": self.case_import_info.sodar_uuid}, @@ -375,11 +397,13 @@ def test_create(self): with open( os.path.join(os.path.dirname(__file__), "data", "example.tsv"), "rb" ) as upload_file: - response = self.client.post( + response = self.request_knox( reverse( "importer:api-bam-qc-file-list-create", kwargs={"caseimportinfo": self.case_import_info.sodar_uuid}, ), + format="multipart", + method="POST", data={**post_data, "file": upload_file}, ) @@ -393,7 +417,7 @@ def test_create(self): def test_retrieve(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-bam-qc-file-retrieve-destroy", kwargs={ @@ -411,14 +435,15 @@ def test_retrieve(self): def test_destroy(self): with self.login(self.user): - response = self.client.delete( + response = self.request_knox( reverse( "importer:api-bam-qc-file-retrieve-destroy", kwargs={ "caseimportinfo": self.case_import_info.sodar_uuid, "bamqcfile": self.bam_qc_file.sodar_uuid, }, - ) + ), + method="DELETE", ) expected = None @@ -448,7 +473,7 @@ def setUp(self): def test_list(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-genotype-file-list-create", kwargs={"variantsetimportinfo": self.variant_set_import_info.sodar_uuid}, @@ -476,11 +501,13 @@ def test_create(self): with open( os.path.join(os.path.dirname(__file__), "data", "example.tsv"), "rb" ) as upload_file: - response = self.client.post( + response = self.request_knox( reverse( "importer:api-genotype-file-list-create", kwargs={"variantsetimportinfo": self.variant_set_import_info.sodar_uuid}, ), + method="POST", + format="multipart", data={**post_data, "file": upload_file}, ) @@ -494,7 +521,7 @@ def test_create(self): def test_retrieve(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "importer:api-genotype-file-retrieve-destroy", kwargs={ @@ -512,14 +539,15 @@ def test_retrieve(self): def test_destroy(self): with self.login(self.user): - response = self.client.delete( + response = self.request_knox( reverse( "importer:api-genotype-file-retrieve-destroy", kwargs={ "variantsetimportinfo": self.variant_set_import_info.sodar_uuid, "genotypefile": self.genotype_file.sodar_uuid, }, - ) + ), + method="DELETE", ) expected = None diff --git a/importer/views_api.py b/importer/views_api.py index 262916102..6e938fcf5 100644 --- a/importer/views_api.py +++ b/importer/views_api.py @@ -13,6 +13,7 @@ RetrieveDestroyAPIView, ) +from varfish.api_utils import VarfishApiRenderer, VarfishApiVersioning from . import tasks from .models import CaseImportInfo, VariantSetImportInfo, CaseImportState, ImportCaseBgJob from .serializers import ( @@ -39,6 +40,8 @@ class CaseImportInfoListCreateView(SODARAPIGenericProjectMixin, ListCreateAPIVie project_type = SODAR_CONSTANTS["PROJECT_TYPE_PROJECT"] serializer_class = CaseImportInfoSerializer + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning def get_queryset(self): qs = CaseImportInfo.objects.filter(project=self.get_project()) @@ -68,6 +71,8 @@ class CaseImportInfoRetrieveUpdateDestroyView( lookup_url_kwarg = "caseimportinfo" serializer_class = CaseImportInfoSerializer project_type = SODAR_CONSTANTS["PROJECT_TYPE_PROJECT"] + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning def perform_update(self, serializer): old_state = self.get_object().state @@ -127,6 +132,8 @@ def get_queryset(self): class VariantSetImportBaseMixin(SODARAPIBaseProjectMixin, RelatedMixin): serializer_class = VariantSetImportInfoSerializer + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning related_class = CaseImportInfo related_lookup_field = "case_import_info" @@ -165,6 +172,8 @@ def get_permission_required(self): class BamQcFileBaseMixin(SODARAPIBaseProjectMixin, RelatedMixin): serializer_class = BamQcFileSerializer + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning related_class = CaseImportInfo related_lookup_field = "case_import_info" @@ -201,6 +210,8 @@ def get_permission_required(self): class GenotypeFileBaseMixin(SODARAPIBaseProjectMixin, RelatedMixin): serializer_class = GenotypeFileSerializer + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning related_class = VariantSetImportInfo related_lookup_field = "variant_set_import_info" @@ -237,6 +248,8 @@ def get_permission_required(self): class EffectsFileBaseMixin(SODARAPIBaseProjectMixin, RelatedMixin): serializer_class = EffectFileSerializer + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning related_class = VariantSetImportInfo related_lookup_field = "variant_set_import_info" @@ -273,6 +286,8 @@ def get_permission_required(self): class DatabaseInfoFileBaseMixin(SODARAPIBaseProjectMixin, RelatedMixin): serializer_class = DatabaseInfoFileSerializer + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning related_class = VariantSetImportInfo related_lookup_field = "variant_set_import_info" diff --git a/varfish/api_utils.py b/varfish/api_utils.py new file mode 100644 index 000000000..c6992845e --- /dev/null +++ b/varfish/api_utils.py @@ -0,0 +1,24 @@ +"""Constants and utility code for the VarFish REST API. +""" + +import re + +from projectroles.views_api import SODARAPIRenderer, SODARAPIVersioning + +from varfish import __version__ as varfish_version + +# API constants +VARFISH_API_MEDIA_TYPE = "application/vnd.bihealth.varfish+json" +VARFISH_API_DEFAULT_VERSION = re.match(r"^([0-9.]+)(?:[+|\-][\S]+)?$", varfish_version)[1] +VARFISH_API_ALLOWED_VERSIONS = [ + "0.23.9", +] + + +class VarfishApiRenderer(SODARAPIRenderer): + media_type = VARFISH_API_MEDIA_TYPE + + +class VarfishApiVersioning(SODARAPIVersioning): + allowed_versions = VARFISH_API_MEDIA_TYPE + default_version = VARFISH_API_DEFAULT_VERSION diff --git a/variants/tests/helpers.py b/variants/tests/helpers.py index 4652902ca..37f674663 100644 --- a/variants/tests/helpers.py +++ b/variants/tests/helpers.py @@ -2,18 +2,26 @@ from django.test import RequestFactory from test_plus.test import TestCase, APITestCase +from projectroles.tests.test_views_api import SODARAPIViewTestMixin from cohorts.models import Cohort +from varfish.api_utils import VARFISH_API_MEDIA_TYPE, VARFISH_API_DEFAULT_VERSION from .factories import ProcessedFormDataFactory from ..models import Case, CaseAwareProject from variants.helpers import get_engine +#: A known invalid MIME type. +VARFISH_INVALID_MIMETYPE = "application/vnd.bihealth.invalid+json" +#: A known invalid version. +VARFISH_INVALID_VERSION = "0.0.0" + + class TestBase(TestCase): """Base class for all tests.""" -class ViewTestBaseMixin: +class ViewTestBaseMixin(SODARAPIViewTestMixin): def setUp(self): super().setUp() self.maxDiff = None # show full diff @@ -26,10 +34,16 @@ def setUp(self): self.user.is_superuser = True self.user.save() + # Get knox token for self.user + self.knox_token = self.get_token(self.user) + class ApiViewTestBase(ViewTestBaseMixin, APITestCase): """Base class for API view testing (and file export)""" + media_type = VARFISH_API_MEDIA_TYPE + api_version = VARFISH_API_DEFAULT_VERSION + class ViewTestBase(ViewTestBaseMixin, TestCase): """Base class for UI view testing (and file export)""" diff --git a/variants/tests/test_views_api.py b/variants/tests/test_views_api.py index 7ee8c3890..150b13ae0 100644 --- a/variants/tests/test_views_api.py +++ b/variants/tests/test_views_api.py @@ -1,7 +1,9 @@ +from varfish.api_utils import VARFISH_API_DEFAULT_VERSION, VARFISH_API_MEDIA_TYPE + from django.urls import reverse from .factories import CaseWithVariantSetFactory -from .helpers import ApiViewTestBase +from .helpers import ApiViewTestBase, VARFISH_INVALID_VERSION, VARFISH_INVALID_MIMETYPE # TODO: add tests that include permission testing @@ -36,13 +38,33 @@ def _expected_case_data(self, case=None): "release": case.release, } + def _test_list_with_invalid_x( + self, media_type=VARFISH_API_MEDIA_TYPE, version=VARFISH_API_DEFAULT_VERSION + ): + with self.login(self.user): + response = self.request_knox( + reverse( + "variants:api-case-list-create", + kwargs={"project": self.case.project.sodar_uuid}, + ), + media_type=media_type, + version=version, + ) + self.assertEqual(response.status_code, 406) + + def test_list_with_invalid_version(self): + self._test_list_with_invalid_x(version=VARFISH_INVALID_VERSION) + + def test_list_with_invalid_media_type(self): + self._test_list_with_invalid_x(media_type=VARFISH_INVALID_MIMETYPE) + def test_list(self): with self.login(self.user): - response = self.client.get( + response = self.request_knox( reverse( "variants:api-case-list-create", kwargs={"project": self.case.project.sodar_uuid}, - ) + ), ) self.assertEqual(response.status_code, 200, response.content) @@ -101,6 +123,26 @@ def test_list(self): # self.assertEquals(response.data, expected) # self.assertIsNotNone(Case.objects.get(project=self.case.project, sodar_uuid=case_uuid)) + def _test_retrieve_with_invalid_x( + self, media_type=VARFISH_API_MEDIA_TYPE, version=VARFISH_API_DEFAULT_VERSION + ): + with self.login(self.user): + response = self.request_knox( + reverse( + "variants:api-case-retrieve-update-destroy", + kwargs={"project": self.case.project.sodar_uuid, "case": self.case.sodar_uuid}, + ), + media_type=media_type, + version=version, + ) + self.assertEqual(response.status_code, 406) + + def test_retrieve_with_invalid_version(self): + self._test_retrieve_with_invalid_x(version=VARFISH_INVALID_VERSION) + + def test_retrieve_with_invalid_media_type(self): + self._test_retrieve_with_invalid_x(media_type=VARFISH_INVALID_MIMETYPE) + def test_retrieve(self): with self.login(self.user): response = self.client.get( diff --git a/variants/views_api.py b/variants/views_api.py index f1f5ba087..afbc7c02e 100644 --- a/variants/views_api.py +++ b/variants/views_api.py @@ -5,17 +5,18 @@ from projectroles.views_api import SODARAPIGenericProjectMixin from rest_framework.generics import ListAPIView, RetrieveAPIView -# # TOOD: timeline update +from varfish.api_utils import VarfishApiRenderer, VarfishApiVersioning +# # TOOD: timeline update from .models import Case from .serializers import CaseSerializer -class CaseListCreateView( - SODARAPIGenericProjectMixin, ListAPIView, -): +class CaseListCreateView(SODARAPIGenericProjectMixin, ListAPIView): """DRF list-create API view the ``Case`` model.""" + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning serializer_class = CaseSerializer def get_queryset(self): @@ -35,6 +36,8 @@ class CaseListRetrieveUpdateDestroyView( lookup_field = "sodar_uuid" lookup_url_kwarg = "case" + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning serializer_class = CaseSerializer def get_queryset(self): @@ -56,6 +59,8 @@ class CaseListRetrieveUpdateDestroyView( lookup_field = "sodar_uuid" lookup_url_kwarg = "case" + renderer_classes = [VarfishApiRenderer] + versioning_class = VarfishApiVersioning serializer_class = CaseSerializer def get_queryset(self):