Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions api/tests/openapi_tests.py
Original file line number Diff line number Diff line change
@@ -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'])
11 changes: 11 additions & 0 deletions backend/openapi_hooks.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 33 additions & 1 deletion backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
'django_filters',
'django_extensions',
'api.apps.ApiConfig',
'drf_spectacular',
]

MIDDLEWARE = [
Expand Down Expand Up @@ -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': [
Expand All @@ -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/
Expand Down
15 changes: 8 additions & 7 deletions backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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