From 36c1e5a65138119e8616540cd25754c5bda0c099 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Wed, 4 Mar 2026 22:43:06 +0000 Subject: [PATCH] chore: Add support for OpenAPI 3.1 --- api/tests/openapi_tests.py | 120 +++++++++++++++++++++++++++++++++++++ backend/openapi_hooks.py | 11 ++++ backend/settings.py | 34 ++++++++++- backend/urls.py | 15 ++--- requirements.txt | 1 + 5 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 api/tests/openapi_tests.py create mode 100644 backend/openapi_hooks.py diff --git a/api/tests/openapi_tests.py b/api/tests/openapi_tests.py new file mode 100644 index 0000000..8b632c5 --- /dev/null +++ b/api/tests/openapi_tests.py @@ -0,0 +1,120 @@ +import json + +import yaml +from django.test import TestCase +from django.urls import reverse + + +class OpenAPISchemaTests(TestCase): + + SCHEMA_URL = reverse('openapi-schema') + SCHEMA_URL_JSON = SCHEMA_URL + '?format=json' + SCHEMA_URL_YAML = SCHEMA_URL + '?format=yaml' + + def _get_schema(self): + response = self.client.get(self.SCHEMA_URL_JSON) + self.assertEqual(response.status_code, 200) + return json.loads(response.content) + + def test_schema_default_format_returns_valid_yaml_on_default_url(self): + response = self.client.get(self.SCHEMA_URL) + self.assertEqual(response.status_code, 200) + schema = yaml.safe_load(response.content) + self.assertIn('openapi', schema) + self.assertIn('paths', schema) + + def test_schema_default_format_returns_valid_yaml(self): + response = self.client.get(self.SCHEMA_URL_YAML) + self.assertEqual(response.status_code, 200) + schema = yaml.safe_load(response.content) + self.assertIn('openapi', schema) + self.assertIn('paths', schema) + + def test_schema_returns_200(self): + response = self.client.get(self.SCHEMA_URL_JSON) + self.assertEqual(response.status_code, 200) + + def test_schema_is_valid_json(self): + schema = self._get_schema() + self.assertIn('openapi', schema) + self.assertIn('paths', schema) + self.assertIn('info', schema) + + def test_schema_version_is_3_1(self): + schema = self._get_schema() + self.assertTrue(schema['openapi'].startswith('3.1')) + + def test_schema_info(self): + schema = self._get_schema() + self.assertEqual(schema['info']['title'], 'Marketing API') + + def test_schema_contains_public_and_private_tags(self): + schema = self._get_schema() + tag_names = [t['name'] for t in schema.get('tags', [])] + self.assertIn('Public', tag_names) + self.assertIn('Private', tag_names) + + def test_schema_contains_oauth2_security_scheme(self): + schema = self._get_schema() + security_schemes = schema.get('components', {}).get('securitySchemes', {}) + self.assertIn('OAuth2', security_schemes) + self.assertEqual(security_schemes['OAuth2']['type'], 'oauth2') + + def test_schema_has_paths(self): + schema = self._get_schema() + self.assertGreater(len(schema['paths']), 0) + + def test_public_endpoints_have_no_security(self): + schema = self._get_schema() + for path, methods in schema['paths'].items(): + if path.startswith('/api/public/'): + for method, spec in methods.items(): + if method in ('get', 'post', 'put', 'patch', 'delete'): + self.assertEqual( + spec.get('security', []), + [], + f'{method.upper()} {path} should have empty security' + ) + + + def test_admin_endpoints_not_in_schema(self): + schema = self._get_schema() + for path in schema['paths']: + self.assertFalse( + path.startswith('/admin'), + f'Admin endpoint {path} should not appear in schema' + ) + + def test_all_paths_are_under_api_prefix(self): + schema = self._get_schema() + for path in schema['paths']: + self.assertTrue( + path.startswith('/api/'), + f'Path {path} should be under /api/ prefix' + ) + + +class SwaggerUITests(TestCase): + + DOCS_URL = reverse('swagger-ui') + + def test_swagger_ui_returns_200(self): + response = self.client.get(self.DOCS_URL) + self.assertEqual(response.status_code, 200) + + def test_swagger_ui_contains_html(self): + response = self.client.get(self.DOCS_URL) + self.assertIn('text/html', response['Content-Type']) + + +class RedocTests(TestCase): + + DOCS_URL = reverse('redoc') + + def test_redoc_returns_200(self): + response = self.client.get(self.DOCS_URL) + self.assertEqual(response.status_code, 200) + + def test_redoc_contains_html(self): + response = self.client.get(self.DOCS_URL) + self.assertIn('text/html', response['Content-Type']) diff --git a/backend/openapi_hooks.py b/backend/openapi_hooks.py new file mode 100644 index 0000000..0e4f787 --- /dev/null +++ b/backend/openapi_hooks.py @@ -0,0 +1,11 @@ +def custom_postprocessing_hook(result, generator, request, public): + for path, methods in result.get('paths', {}).items(): + is_public = path.startswith('/api/public/') + tag = 'Public' if is_public else 'Private' + for method, operation in methods.items(): + if not isinstance(operation, dict): + continue + operation['tags'] = [tag] + if is_public: + operation['security'] = [] + return result diff --git a/backend/settings.py b/backend/settings.py index cb0d145..b7a2b14 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -72,6 +72,7 @@ 'django_filters', 'django_extensions', 'api.apps.ApiConfig', + 'drf_spectacular', ] MIDDLEWARE = [ @@ -298,6 +299,8 @@ 'SEARCH_PARAM': 'filter', 'ORDERING_PARAM': 'order', 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning', + 'DEFAULT_VERSION': 'v1', + 'ALLOWED_VERSIONS': ['v1'], 'DEFAULT_PAGINATION_CLASS': 'api.utils.pagination.LargeResultsSetPagination', 'PAGE_SIZE': 100, 'DEFAULT_THROTTLE_CLASSES': [ @@ -307,7 +310,36 @@ 'DEFAULT_THROTTLE_RATES': { 'anon': '1000/min', 'user': '10000/min' - } + }, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Marketing API', + 'DESCRIPTION': 'API for managing marketing configuration values', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'OAS_VERSION': '3.1.0', + 'POSTPROCESSING_HOOKS': ['backend.openapi_hooks.custom_postprocessing_hook'], + 'EXCLUDE_PATH_REGEX': r'^/admin', + 'TAGS': [ + {'name': 'Public', 'description': 'Unauthenticated read endpoints'}, + {'name': 'Private', 'description': 'OAuth2-protected write endpoints'}, + ], + 'SECURITY': [{'OAuth2': []}], + 'APPEND_COMPONENTS': { + 'securitySchemes': { + 'OAuth2': { + 'type': 'oauth2', + 'flows': { + 'clientCredentials': { + 'tokenUrl': '{}/oauth/token'.format(os.getenv('OAUTH2_IDP_BASE_URL', 'http://localhost:8007')), + 'scopes': {}, + }, + }, + }, + }, + }, } # https://docs.djangoproject.com/en/3.0/ref/settings/ diff --git a/backend/urls.py b/backend/urls.py index 91e558a..15a3ba6 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -19,7 +19,11 @@ from django.conf.urls.static import static from api.urls import public_urlpatterns as public_api_v1 from api.urls import private_urlpatterns as private_api_v1 -from rest_framework.schemas import get_schema_view +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) api_urlpatterns = [ @@ -30,12 +34,9 @@ urlpatterns = [ path('api/', include(api_urlpatterns)), path('admin', admin.site.urls), - path('openapi', get_schema_view( - title="Marketing API", - description="Marketing API", - version="1.0.0", - patterns=api_urlpatterns, - ), name='openapi-schema'), + path('openapi', SpectacularAPIView.as_view(), name='openapi-schema'), + path('api/docs', SpectacularSwaggerView.as_view(url_name='openapi-schema'), name='swagger-ui'), + path('api/redoc', SpectacularRedocView.as_view(url_name='openapi-schema'), name='redoc'), ] if settings.DEBUG: diff --git a/requirements.txt b/requirements.txt index 632bc0b..7cd5b7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -66,3 +66,4 @@ uritemplate==4.2.0 urllib3==2.5.0 Werkzeug==3.1.4 wrapt==2.0.1 +drf-spectacular==0.28.0