Skip to content

Commit 5d62e85

Browse files
feat: Add OpenAPI 3.1 documentation
1 parent 724f5f1 commit 5d62e85

8 files changed

Lines changed: 378 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Django + Graphene (GraphQL) read-only reporting API for OpenStack/OpenInfra summit data. It connects to an external MySQL database (`openstack_db`) and exposes a single GraphQL endpoint at `/reports`. Authentication is handled via OAuth2 token introspection against an IDP.
8+
9+
## Common Commands
10+
11+
```bash
12+
# Local dev with Docker (recommended)
13+
docker compose up -d # Start all services (app, MySQL, Redis)
14+
./start_local_server.sh # Migrate + start + shell into container
15+
16+
# Without Docker
17+
docker compose exec app python manage.py runserver 0.0.0.0:8003
18+
docker compose exec app python manage.py migrate --database=openstack_db
19+
20+
# Tests (require openstack_db connection)
21+
docker compose exec app python manage.py test reports_api.reports.tests.openapi_test_case
22+
23+
# Dependencies
24+
pip install -r requirements.txt
25+
```
26+
27+
## Architecture
28+
29+
### Database Design
30+
31+
- **Two databases**: `default` (SQLite in-memory, unused) and `openstack_db` (external MySQL with the actual summit data)
32+
- **Read-only**: `DBRouter` (`reports_api/db_router.py`) routes all `reports` app reads to `openstack_db` and blocks writes/migrations
33+
- All models use `managed = False` — they map to existing MySQL tables, not Django-managed schema
34+
- Redis is used for caching (token info and GraphQL query results via `graphene_django_extras`)
35+
36+
### Request Flow
37+
38+
1. `TokenValidationMiddleware` (`reports_api/authentication.py`) validates OAuth2 bearer tokens by introspecting against the IDP, with Redis caching
39+
2. Single URL route `/reports` serves GraphQL via `GraphQLView` with GraphiQL enabled
40+
3. Root schema (`reports_api/schema.py`) delegates to `reports_api/reports/schema.py` which defines all queries
41+
42+
### Key Modules
43+
44+
- **`reports_api/reports/schema.py`** — All GraphQL types (`*Node`), list types (`*ListType`), serializer types (`*ModelType`), custom resolvers, and the `Query` class. This is the largest file and the main entry point for report logic.
45+
- **`reports_api/reports/filters/model_filters.py`**`django_filters` FilterSets for all queryable entities (speakers, presentations, events, attendees, metrics, etc.). Complex filters use subqueries and annotations.
46+
- **`reports_api/reports/models/`** — Django models mapping to existing MySQL tables. Organized into subdirectories: `registration/`, `rsvp/`, `extra_questions/`.
47+
- **`reports_api/reports/serializers/model_serializers.py`** — DRF serializers used by `DjangoSerializerType` for Speaker, Presentation, Attendee, etc.
48+
49+
### GraphQL Patterns
50+
51+
- Uses `graphene-django-extras` for pagination (`LimitOffsetGraphqlPagination`), list types, and serializer types
52+
- Custom `DjangoListObjectField` subclasses (`SpeakerModelDjangoListObjectField`, `AttendeeModelDjangoListObjectField`) override `list_resolver` to inject annotations for filtering
53+
- Raw SQL queries are used for metric aggregation (`getUniqueMetrics` in schema.py)
54+
- `SubqueryCount` / `SubQueryCount` / `SubQueryAvg` are custom Subquery helpers used in filters and schema
55+
56+
### OpenAPI Documentation
57+
58+
- Uses `drf-spectacular` for OpenAPI 3.1 schema generation
59+
- Endpoints: `/openapi` (schema), `/api/docs` (Swagger UI), `/api/redoc` (ReDoc)
60+
- These paths are exempt from OAuth2 in `TokenValidationMiddleware.EXEMPT_PATHS`
61+
- `openapi_hooks.py` tags paths as Public/Private and strips security from `/api/public/` paths
62+
- `UNAUTHENTICATED_USER` is set to `None` in `REST_FRAMEWORK` because `django.contrib.auth` is not installed — DRF's default `AnonymousUser` would fail without it
63+
- Tests: `reports_api/reports/tests/openapi_test_case.py`
64+
65+
### Environment Variables
66+
67+
Configured via `.env` file (see `.env.template`). Key vars: `DB_OPENSTACK_*` (database), `REDIS_*` (cache), `RS_CLIENT_*` + `IDP_*` (OAuth2), `SECRET_KEY`.

reports_api/authentication.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ class TokenValidationMiddleware(object):
2222
def __init__(self, get_response):
2323
self.get_response = get_response
2424

25+
EXEMPT_PATHS = ('/openapi', '/api/docs', '/api/redoc')
26+
2527
def __call__(self, request):
26-
#return self.get_response(request)
27-
28+
if request.path.rstrip('/') in self.EXEMPT_PATHS:
29+
return self.get_response(request)
30+
2831
try:
2932
access_token = TokenValidationMiddleware.get_access_token(request)
3033
if access_token is None:

reports_api/openapi_hooks.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
_GRAPHQL_PATH = '/reports'
2+
3+
_GRAPHQL_REQUEST_SCHEMA = {
4+
'type': 'object',
5+
'required': ['query'],
6+
'properties': {
7+
'query': {
8+
'type': 'string',
9+
'description': 'A GraphQL query or mutation string.',
10+
'example': '{ speakers(summitId: 1) { results { id fullName } } }',
11+
},
12+
'variables': {
13+
'type': 'object',
14+
'nullable': True,
15+
'description': 'A JSON object of variable values referenced by the query.',
16+
'additionalProperties': True,
17+
},
18+
'operationName': {
19+
'type': 'string',
20+
'nullable': True,
21+
'description': 'If the query contains multiple operations, the name of the one to execute.',
22+
},
23+
},
24+
}
25+
26+
_GRAPHQL_RESPONSE_SCHEMA = {
27+
'type': 'object',
28+
'properties': {
29+
'data': {
30+
'type': 'object',
31+
'nullable': True,
32+
'description': 'The data returned by the GraphQL query.',
33+
'additionalProperties': True,
34+
},
35+
'errors': {
36+
'type': 'array',
37+
'nullable': True,
38+
'description': 'A list of errors that occurred during execution.',
39+
'items': {
40+
'type': 'object',
41+
'properties': {
42+
'message': {'type': 'string'},
43+
'locations': {
44+
'type': 'array',
45+
'items': {
46+
'type': 'object',
47+
'properties': {
48+
'line': {'type': 'integer'},
49+
'column': {'type': 'integer'},
50+
},
51+
},
52+
},
53+
'path': {
54+
'type': 'array',
55+
'items': {'type': 'string'},
56+
},
57+
},
58+
},
59+
},
60+
},
61+
}
62+
63+
_GRAPHQL_PATH_ITEM = {
64+
'post': {
65+
'operationId': 'graphql_query',
66+
'summary': 'Execute a GraphQL query',
67+
'description': (
68+
'Single GraphQL endpoint for all summit report queries. '
69+
'Accepts a GraphQL document in the request body and returns the query result.\n\n'
70+
'Authentication is required via OAuth2 bearer token. '
71+
'The GraphiQL interactive explorer is available at this URL when accessed from a browser (GET).'
72+
),
73+
'tags': ['Private'],
74+
'security': [{'OAuth2': []}],
75+
'requestBody': {
76+
'required': True,
77+
'content': {
78+
'application/json': {
79+
'schema': _GRAPHQL_REQUEST_SCHEMA,
80+
},
81+
},
82+
},
83+
'responses': {
84+
'200': {
85+
'description': (
86+
'GraphQL response. Note: errors during query execution are returned '
87+
'with HTTP 200 inside the `errors` field.'
88+
),
89+
'content': {
90+
'application/json': {
91+
'schema': _GRAPHQL_RESPONSE_SCHEMA,
92+
},
93+
},
94+
},
95+
'400': {'description': 'Malformed request (e.g. missing or unparseable `query` field).'},
96+
'401': {'description': 'Missing or invalid OAuth2 bearer token.'},
97+
},
98+
},
99+
'get': {
100+
'operationId': 'graphql_explorer',
101+
'summary': 'Open the GraphiQL interactive explorer',
102+
'description': 'Returns the GraphiQL browser UI for exploring the GraphQL schema interactively.',
103+
'tags': ['Private'],
104+
'security': [{'OAuth2': []}],
105+
'responses': {
106+
'200': {
107+
'description': 'GraphiQL HTML interface.',
108+
'content': {'text/html': {'schema': {'type': 'string'}}},
109+
},
110+
'401': {'description': 'Missing or invalid OAuth2 bearer token.'},
111+
},
112+
},
113+
}
114+
115+
116+
def custom_postprocessing_hook(result, generator, request, public):
117+
for path, methods in result.get('paths', {}).items():
118+
is_public = path.startswith('/api/public/')
119+
tag = 'Public' if is_public else 'Private'
120+
for method, operation in methods.items():
121+
if not isinstance(operation, dict):
122+
continue
123+
operation['tags'] = [tag]
124+
if is_public:
125+
operation['security'] = []
126+
127+
paths = result.setdefault('paths', {})
128+
if _GRAPHQL_PATH not in paths:
129+
paths[_GRAPHQL_PATH] = _GRAPHQL_PATH_ITEM
130+
131+
return result

reports_api/openapi_views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from drf_spectacular.views import (
2+
SpectacularAPIView,
3+
SpectacularSwaggerView,
4+
SpectacularRedocView,
5+
)
6+
7+
8+
# Subclass drf-spectacular views to disable throttling (which requires Redis).
9+
# Plain function wrappers prevent django-injector from trying to inject
10+
# drf-spectacular's type-hinted parameters.
11+
12+
class _SchemaView(SpectacularAPIView):
13+
throttle_classes = []
14+
15+
16+
class _SwaggerView(SpectacularSwaggerView):
17+
throttle_classes = []
18+
19+
20+
class _RedocView(SpectacularRedocView):
21+
throttle_classes = []
22+
23+
24+
_schema_view = _SchemaView.as_view()
25+
_swagger_view = _SwaggerView.as_view(url_name='openapi-schema')
26+
_redoc_view = _RedocView.as_view(url_name='openapi-schema')
27+
28+
29+
def schema_view(request, *args, **kwargs):
30+
return _schema_view(request, *args, **kwargs)
31+
32+
33+
def swagger_view(request, *args, **kwargs):
34+
return _swagger_view(request, *args, **kwargs)
35+
36+
37+
def redoc_view(request, *args, **kwargs):
38+
return _redoc_view(request, *args, **kwargs)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import json
2+
3+
import yaml
4+
from django.test import TestCase
5+
from django.urls import reverse
6+
7+
8+
class OpenAPISchemaTests(TestCase):
9+
10+
SCHEMA_URL = reverse('openapi-schema')
11+
SCHEMA_URL_JSON = SCHEMA_URL + '?format=json'
12+
SCHEMA_URL_YAML = SCHEMA_URL + '?format=yaml'
13+
14+
def _get_schema(self):
15+
response = self.client.get(self.SCHEMA_URL_JSON)
16+
self.assertEqual(response.status_code, 200)
17+
return json.loads(response.content)
18+
19+
def test_schema_default_format_returns_valid_yaml_on_default_url(self):
20+
response = self.client.get(self.SCHEMA_URL)
21+
self.assertEqual(response.status_code, 200)
22+
schema = yaml.safe_load(response.content)
23+
self.assertIn('openapi', schema)
24+
self.assertIn('paths', schema)
25+
26+
def test_schema_default_format_returns_valid_yaml(self):
27+
response = self.client.get(self.SCHEMA_URL_YAML)
28+
self.assertEqual(response.status_code, 200)
29+
schema = yaml.safe_load(response.content)
30+
self.assertIn('openapi', schema)
31+
self.assertIn('paths', schema)
32+
33+
def test_schema_returns_200(self):
34+
response = self.client.get(self.SCHEMA_URL_JSON)
35+
self.assertEqual(response.status_code, 200)
36+
37+
def test_schema_is_valid_json(self):
38+
schema = self._get_schema()
39+
self.assertIn('openapi', schema)
40+
self.assertIn('paths', schema)
41+
self.assertIn('info', schema)
42+
43+
def test_schema_version_is_3_1(self):
44+
schema = self._get_schema()
45+
self.assertTrue(schema['openapi'].startswith('3.1'))
46+
47+
def test_schema_info(self):
48+
schema = self._get_schema()
49+
self.assertEqual(schema['info']['title'], 'Summit Reports API')
50+
51+
def test_schema_contains_public_and_private_tags(self):
52+
schema = self._get_schema()
53+
tag_names = [t['name'] for t in schema.get('tags', [])]
54+
self.assertIn('Public', tag_names)
55+
self.assertIn('Private', tag_names)
56+
57+
def test_schema_contains_oauth2_security_scheme(self):
58+
schema = self._get_schema()
59+
security_schemes = schema.get('components', {}).get('securitySchemes', {})
60+
self.assertIn('OAuth2', security_schemes)
61+
self.assertEqual(security_schemes['OAuth2']['type'], 'oauth2')
62+
63+
def test_schema_has_paths_key(self):
64+
schema = self._get_schema()
65+
self.assertIn('paths', schema)
66+
67+
68+
class SwaggerUITests(TestCase):
69+
70+
DOCS_URL = reverse('swagger-ui')
71+
72+
def test_swagger_ui_returns_200(self):
73+
response = self.client.get(self.DOCS_URL)
74+
self.assertEqual(response.status_code, 200)
75+
76+
def test_swagger_ui_contains_html(self):
77+
response = self.client.get(self.DOCS_URL)
78+
self.assertIn('text/html', response['Content-Type'])
79+
80+
81+
class RedocTests(TestCase):
82+
83+
DOCS_URL = reverse('redoc')
84+
85+
def test_redoc_returns_200(self):
86+
response = self.client.get(self.DOCS_URL)
87+
self.assertEqual(response.status_code, 200)
88+
89+
def test_redoc_contains_html(self):
90+
response = self.client.get(self.DOCS_URL)
91+
self.assertIn('text/html', response['Content-Type'])

0 commit comments

Comments
 (0)