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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
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)
Comment thread
matiasperrone-exo marked this conversation as resolved.
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'])
25 changes: 25 additions & 0 deletions backend/openapi_schema.py
Original file line number Diff line number Diff line change
@@ -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': []}]
32 changes: 31 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,34 @@
'DEFAULT_THROTTLE_RATES': {
'anon': '1000/min',
'user': '10000/min'
}
},
'DEFAULT_SCHEMA_CLASS': 'backend.openapi_schema.MarketingAutoSchema',
}

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',
'EXCLUDE_PATH_REGEX': r'^/admin',
'TAGS': [
{'name': 'Public', 'description': 'Unauthenticated read endpoints'},
{'name': 'Private', 'description': 'OAuth2-protected write endpoints'},
],
'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'),
Comment thread
matiasperrone-exo marked this conversation as resolved.
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
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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:
Expand All @@ -58,4 +58,4 @@ volumes:
marketing_db_data:
networks:
marketing-api-local-net:
driver: bridge
driver: bridge
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