From 36c1e5a65138119e8616540cd25754c5bda0c099 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Wed, 4 Mar 2026 22:43:06 +0000 Subject: [PATCH 1/2] 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 From ae89bf99506212a48039303a01600834101b5268 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 20 Mar 2026 20:47:18 +0000 Subject: [PATCH 2/2] chore: Add PR's requested changes --- Dockerfile | 2 +- backend/openapi_hooks.py | 11 ----------- backend/openapi_schema.py | 25 +++++++++++++++++++++++++ backend/settings.py | 4 +--- docker-compose.yml | 6 +++--- 5 files changed, 30 insertions(+), 18 deletions(-) delete mode 100644 backend/openapi_hooks.py create mode 100644 backend/openapi_schema.py diff --git a/Dockerfile b/Dockerfile index 3128e81..5d450cf 100755 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /opt/project # Install dependencies COPY requirements.txt /opt/project/ RUN pip install --upgrade pip \ - && pip install --no-cache-dir -r requirements.txt + && pip install --no-cache-dir -r requirements.txt # Copy the project COPY . /opt/project/ diff --git a/backend/openapi_hooks.py b/backend/openapi_hooks.py deleted file mode 100644 index 0e4f787..0000000 --- a/backend/openapi_hooks.py +++ /dev/null @@ -1,11 +0,0 @@ -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/openapi_schema.py b/backend/openapi_schema.py new file mode 100644 index 0000000..b8517fc --- /dev/null +++ b/backend/openapi_schema.py @@ -0,0 +1,25 @@ +from drf_spectacular.openapi import AutoSchema +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.plumbing import build_basic_type +from drf_spectacular.utils import Direction +from rest_framework import serializers as drf_serializers +from api.security import OAuth2Authentication +from api.serializers.config_value_read_serializer_list import TimestampField + + +class TimestampFieldExtension(OpenApiSerializerFieldExtension): + target_class = TimestampField + + def map_serializer_field(self, auto_schema, direction: Direction): + return build_basic_type(int) + + +class MarketingAutoSchema(AutoSchema): + def get_tags(self): + is_private = OAuth2Authentication in getattr(self.view, 'authentication_classes', []) + return ['Private'] if is_private else ['Public'] + + def get_auth(self): + if OAuth2Authentication not in getattr(self.view, 'authentication_classes', []): + return [] + return [{'OAuth2': []}] diff --git a/backend/settings.py b/backend/settings.py index b7a2b14..ccb16b9 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -311,7 +311,7 @@ 'anon': '1000/min', 'user': '10000/min' }, - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_SCHEMA_CLASS': 'backend.openapi_schema.MarketingAutoSchema', } SPECTACULAR_SETTINGS = { @@ -320,13 +320,11 @@ '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': { diff --git a/docker-compose.yml b/docker-compose.yml index eb137d1..fa2da05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: condition: service_healthy redis: image: redis:latest - restart: always + restart: unless-stopped command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD} ports: - ${REDIS_PORT} @@ -45,7 +45,7 @@ services: SERVICE_TAGS: dev SERVICE_NAME: mysql healthcheck: - test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 volumes: @@ -58,4 +58,4 @@ volumes: marketing_db_data: networks: marketing-api-local-net: - driver: bridge \ No newline at end of file + driver: bridge