diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py index 28513017b9bf4b43a669921f788bd60a7aae52eb..d0eeb0b6d5fed58ac681e4b819dfd26935e5f657 100644 --- a/arkindex/project/api_v1.py +++ b/arkindex/project/api_v1.py @@ -1,5 +1,9 @@ from django.urls import path from django.views.generic.base import RedirectView +from django.views.decorators.cache import cache_page +from rest_framework.schemas import get_schema_view +from arkindex.project.openapi import SchemaGenerator +import arkindex from arkindex.documents.api.elements import ( ElementsList, RelatedElementsList, ElementRetrieve, CorpusList, CorpusRetrieve, ElementTranscriptions, @@ -32,6 +36,9 @@ from arkindex.users.api import ( UserRetrieve, UserCreate, UserEmailLogin, UserEmailVerification, PasswordReset, PasswordResetConfirm, ) +# Cache the OpenAPI schema view for a day +schema_view = cache_page(86400, key_prefix=arkindex.VERSION)(get_schema_view(generator_class=SchemaGenerator)) + api = [ # Elements @@ -160,4 +167,7 @@ api = [ # Management tools path('reindex/', ReindexStart.as_view(), name='reindex-start'), + + # OpenAPI Schema + path('openapi/', schema_view, name='openapi-schema'), ] diff --git a/arkindex/project/openapi/generators.py b/arkindex/project/openapi/generators.py index 11b239f991bc695f5a1047b0c36142ec7ffa71e1..2e2a58ea35bbaa391a41101d4256bcad5345ebfc 100644 --- a/arkindex/project/openapi/generators.py +++ b/arkindex/project/openapi/generators.py @@ -7,14 +7,32 @@ 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) - def get_schema(self, **kwargs): with PATCH_FILE.open() as f: patch = yaml.safe_load(f) - schema = super().get_schema(**kwargs) + self.patch_paths(schema['paths'], patch.pop('paths', {})) schema.update(patch) schema['info']['version'] = arkindex.VERSION + return schema def patch_paths(self, paths, patches): diff --git a/arkindex/project/openapi/patch.yml b/arkindex/project/openapi/patch.yml index 1ef26a664196ea5610d30e2d7ce9594b20d9cbec..660a1fbde78e635f09b186837eb6c24755f8077e 100644 --- a/arkindex/project/openapi/patch.yml +++ b/arkindex/project/openapi/patch.yml @@ -3,7 +3,7 @@ info: contact: name: Teklia url: https://www.teklia.com/ - email: paris@teklia.com + email: contact@teklia.com components: securitySchemes: sessionAuth: diff --git a/arkindex/project/tests/openapi/__init__.py b/arkindex/project/tests/openapi/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/arkindex/project/tests/openapi/test_generators.py b/arkindex/project/tests/openapi/test_generators.py new file mode 100644 index 0000000000000000000000000000000000000000..24547721fe4ab0a0e79231eb7699882a03fff93d --- /dev/null +++ b/arkindex/project/tests/openapi/test_generators.py @@ -0,0 +1,90 @@ +from unittest import TestCase +from unittest.mock import patch +from arkindex.project.openapi import SchemaGenerator + + +class TestSchemaGenerator(TestCase): + + @patch('arkindex.project.openapi.generators.arkindex.VERSION', new='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/test_openapi.py b/arkindex/project/tests/openapi/test_schemas.py similarity index 81% rename from arkindex/project/tests/test_openapi.py rename to arkindex/project/tests/openapi/test_schemas.py index cb5518a99ac7d04c1899c73798b22606dd0e0ba5..cf07415cbbd3d48e12339226e96ef046d6a0283c 100644 --- a/arkindex/project/tests/test_openapi.py +++ b/arkindex/project/tests/openapi/test_schemas.py @@ -1,5 +1,4 @@ from unittest import TestCase -from unittest.mock import patch from django.test import RequestFactory from rest_framework import serializers from rest_framework.request import Request @@ -24,7 +23,7 @@ def create_view(view_cls, method, request): return view -class TestOpenAPI(TestCase): +class TestAutoSchema(TestCase): def test_no_deprecated(self): """ @@ -370,87 +369,3 @@ class TestOpenAPI(TestCase): } } ) - - @patch('arkindex.project.openapi.generators.arkindex.VERSION', new='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_view.py b/arkindex/project/tests/openapi/test_view.py new file mode 100644 index 0000000000000000000000000000000000000000..8060fd626383d1ae536d1ab879341770b6fdbe46 --- /dev/null +++ b/arkindex/project/tests/openapi/test_view.py @@ -0,0 +1,39 @@ +from arkindex.project.tests import FixtureAPITestCase +from io import StringIO +from django.core.management import call_command +from django.urls import reverse +from rest_framework import status + + +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. + + 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", + and it is much faster to render. + """ + stdout = StringIO() + call_command( + 'generateschema', + generator_class='arkindex.project.openapi.SchemaGenerator', + format='openapi-json', + stdout=stdout, + ) + expected_schema = stdout.getvalue().strip() + + def _test_schema(): + response = self.client.get(reverse('api:openapi-schema') + '?format=openapi-json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.content.decode('utf-8'), expected_schema) + + _test_schema() + + self.client.force_login(self.user) + _test_schema() + + self.client.force_login(self.superuser) + _test_schema()