diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e1fffc6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +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. + +## Common Commands + +```bash +# Local dev with Docker (recommended) +docker compose up -d # Start all services (app, MySQL, Redis) +./start_local_server.sh # Migrate + start + shell into container + +# Without Docker +docker compose exec app python manage.py runserver 0.0.0.0:8003 +docker compose exec app python manage.py migrate --database=openstack_db + +# Tests (require openstack_db connection) +docker compose exec app python manage.py test reports_api.reports.tests.openapi_test_case + +# Dependencies +pip install -r requirements.txt +``` + +## Architecture + +### Database Design + +- **Two databases**: `default` (SQLite in-memory, unused) and `openstack_db` (external MySQL with the actual summit data) +- **Read-only**: `DBRouter` (`reports_api/db_router.py`) routes all `reports` app reads to `openstack_db` and blocks writes/migrations +- All models use `managed = False` — they map to existing MySQL tables, not Django-managed schema +- Redis is used for caching (token info and GraphQL query results via `graphene_django_extras`) + +### Request Flow + +1. `TokenValidationMiddleware` (`reports_api/authentication.py`) validates OAuth2 bearer tokens by introspecting against the IDP, with Redis caching +2. Single URL route `/reports` serves GraphQL via `GraphQLView` with GraphiQL enabled +3. Root schema (`reports_api/schema.py`) delegates to `reports_api/reports/schema.py` which defines all queries + +### Key Modules + +- **`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. +- **`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. +- **`reports_api/reports/models/`** — Django models mapping to existing MySQL tables. Organized into subdirectories: `registration/`, `rsvp/`, `extra_questions/`. +- **`reports_api/reports/serializers/model_serializers.py`** — DRF serializers used by `DjangoSerializerType` for Speaker, Presentation, Attendee, etc. + +### GraphQL Patterns + +- Uses `graphene-django-extras` for pagination (`LimitOffsetGraphqlPagination`), list types, and serializer types +- Custom `DjangoListObjectField` subclasses (`SpeakerModelDjangoListObjectField`, `AttendeeModelDjangoListObjectField`) override `list_resolver` to inject annotations for filtering +- Raw SQL queries are used for metric aggregation (`getUniqueMetrics` in schema.py) +- `SubqueryCount` / `SubQueryCount` / `SubQueryAvg` are custom Subquery helpers used in filters and schema + +### OpenAPI Documentation + +- Uses `drf-spectacular` for OpenAPI 3.1 schema generation +- Endpoints: `/openapi` (schema), `/api/docs` (Swagger UI), `/api/redoc` (ReDoc) +- These paths are exempt from OAuth2 in `TokenValidationMiddleware.EXEMPT_PATHS` +- `openapi_hooks.py` tags paths as Public/Private and strips security from `/api/public/` paths +- `UNAUTHENTICATED_USER` is set to `None` in `REST_FRAMEWORK` because `django.contrib.auth` is not installed — DRF's default `AnonymousUser` would fail without it +- Tests: `reports_api/reports/tests/openapi_test_case.py` + +### Environment Variables + +Configured via `.env` file (see `.env.template`). Key vars: `DB_OPENSTACK_*` (database), `REDIS_*` (cache), `RS_CLIENT_*` + `IDP_*` (OAuth2), `SECRET_KEY`. diff --git a/reports_api/authentication.py b/reports_api/authentication.py index 1a9e21b..b3b7091 100644 --- a/reports_api/authentication.py +++ b/reports_api/authentication.py @@ -22,9 +22,12 @@ class TokenValidationMiddleware(object): def __init__(self, get_response): self.get_response = get_response + EXEMPT_PATHS = ('/openapi', '/api/docs', '/api/redoc') + def __call__(self, request): - #return self.get_response(request) - + if request.path.rstrip('/') in self.EXEMPT_PATHS: + return self.get_response(request) + try: access_token = TokenValidationMiddleware.get_access_token(request) if access_token is None: diff --git a/reports_api/openapi_hooks.py b/reports_api/openapi_hooks.py new file mode 100644 index 0000000..7397724 --- /dev/null +++ b/reports_api/openapi_hooks.py @@ -0,0 +1,127 @@ +_GRAPHQL_PATH = '/reports' + +_GRAPHQL_REQUEST_SCHEMA = { + 'type': 'object', + 'required': ['query'], + 'properties': { + 'query': { + 'type': 'string', + 'description': 'A GraphQL query or mutation string.', + 'example': '{ speakers(summitId: 1) { results { id fullName } } }', + }, + 'variables': { + 'type': ['object', 'null'], + 'description': 'A JSON object of variable values referenced by the query.', + 'additionalProperties': True, + }, + 'operationName': { + 'type': ['string', 'null'], + 'description': 'If the query contains multiple operations, the name of the one to execute.', + }, + }, +} + +_GRAPHQL_RESPONSE_SCHEMA = { + 'type': 'object', + 'properties': { + 'data': { + 'type': ['object', 'null'], + 'description': 'The data returned by the GraphQL query.', + 'additionalProperties': True, + }, + 'errors': { + 'type': ['array', 'null'], + 'description': 'A list of errors that occurred during execution.', + 'items': { + 'type': 'object', + 'properties': { + 'message': {'type': 'string'}, + 'locations': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'line': {'type': 'integer'}, + 'column': {'type': 'integer'}, + }, + }, + }, + 'path': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + }, + }, + }, +} + +_GRAPHQL_PATH_ITEM = { + 'post': { + 'operationId': 'graphql_query', + 'summary': 'Execute a GraphQL query', + 'description': ( + 'Single GraphQL endpoint for all summit report queries. ' + 'Accepts a GraphQL document in the request body and returns the query result.\n\n' + 'Authentication is required via OAuth2 bearer token. ' + 'The GraphiQL interactive explorer is available at this URL when accessed from a browser (GET).' + ), + 'tags': ['Private'], + 'security': [{'OAuth2': []}], + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': _GRAPHQL_REQUEST_SCHEMA, + }, + }, + }, + 'responses': { + '200': { + 'description': ( + 'GraphQL response. Note: errors during query execution are returned ' + 'with HTTP 200 inside the `errors` field.' + ), + 'content': { + 'application/json': { + 'schema': _GRAPHQL_RESPONSE_SCHEMA, + }, + }, + }, + '400': {'description': 'Malformed request (e.g. missing or unparseable `query` field).'}, + '401': {'description': 'Missing or invalid OAuth2 bearer token.'}, + }, + }, + 'get': { + 'operationId': 'graphql_explorer', + 'summary': 'Open the GraphiQL interactive explorer', + 'description': 'Returns the GraphiQL browser UI for exploring the GraphQL schema interactively.', + 'tags': ['Private'], + 'security': [{'OAuth2': []}], + 'responses': { + '200': { + 'description': 'GraphiQL HTML interface.', + 'content': {'text/html': {'schema': {'type': 'string'}}}, + }, + '401': {'description': 'Missing or invalid OAuth2 bearer token.'}, + }, + }, +} + + +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'] = [] + + paths = result.setdefault('paths', {}) + if _GRAPHQL_PATH not in paths: + paths[_GRAPHQL_PATH] = _GRAPHQL_PATH_ITEM + + return result diff --git a/reports_api/openapi_views.py b/reports_api/openapi_views.py new file mode 100644 index 0000000..3f7ccc1 --- /dev/null +++ b/reports_api/openapi_views.py @@ -0,0 +1,38 @@ +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) + + +# Subclass drf-spectacular views to disable throttling (which requires Redis). +# Plain function wrappers prevent django-injector from trying to inject +# drf-spectacular's type-hinted parameters. + +class _SchemaView(SpectacularAPIView): + throttle_classes = [] + + +class _SwaggerView(SpectacularSwaggerView): + throttle_classes = [] + + +class _RedocView(SpectacularRedocView): + throttle_classes = [] + + +_schema_view = _SchemaView.as_view() +_swagger_view = _SwaggerView.as_view(url_name='openapi-schema') +_redoc_view = _RedocView.as_view(url_name='openapi-schema') + + +def schema_view(request, *args, **kwargs): + return _schema_view(request, *args, **kwargs) + + +def swagger_view(request, *args, **kwargs): + return _swagger_view(request, *args, **kwargs) + + +def redoc_view(request, *args, **kwargs): + return _redoc_view(request, *args, **kwargs) diff --git a/reports_api/reports/tests/openapi_test_case.py b/reports_api/reports/tests/openapi_test_case.py new file mode 100644 index 0000000..3526d61 --- /dev/null +++ b/reports_api/reports/tests/openapi_test_case.py @@ -0,0 +1,91 @@ +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'], 'Summit Reports 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_key(self): + schema = self._get_schema() + self.assertIn('paths', schema) + + +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/reports_api/settings.py b/reports_api/settings.py index 2331adf..a4d5371 100644 --- a/reports_api/settings.py +++ b/reports_api/settings.py @@ -30,6 +30,7 @@ ALLOWED_HOSTS = ['*'] +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # Application definition @@ -39,7 +40,9 @@ 'django.contrib.contenttypes', 'graphene_django', 'django_filters', - 'corsheaders' + 'corsheaders', + 'rest_framework', + 'drf_spectacular', ] GRAPHENE = { @@ -185,4 +188,44 @@ 'MAX_PAGE_SIZE': 3000, 'CACHE_ACTIVE': True, 'CACHE_TIMEOUT': 300 # seconds -} \ No newline at end of file +} + +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'UNAUTHENTICATED_USER': None, +} + +# OpenAPI - Spectacular + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Summit Reports API', + 'DESCRIPTION': ( + 'Read-only GraphQL reporting API for OpenStack/OpenInfra summit data. ' + 'Exposes aggregated metrics, speaker lists, presentation details, attendee information, ' + 'and room/event statistics for a given summit. ' + 'All queries are served through a single GraphQL endpoint (`/reports`) ' + 'and require OAuth2 bearer token authentication.' + ), + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'OAS_VERSION': '3.1.0', + 'POSTPROCESSING_HOOKS': ['reports_api.openapi_hooks.custom_postprocessing_hook'], + '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('IDP_BASE_URL', 'http://localhost:8003')), + 'scopes': {}, + }, + }, + }, + }, + }, +} diff --git a/reports_api/urls.py b/reports_api/urls.py index d4448ee..c8c0009 100644 --- a/reports_api/urls.py +++ b/reports_api/urls.py @@ -34,7 +34,11 @@ from graphene_django.views import GraphQLView from django.views.decorators.csrf import csrf_exempt from reports_api.schema import schema +from reports_api.openapi_views import schema_view, swagger_view, redoc_view urlpatterns = [ - path('reports', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))) + path('reports', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))), + path('openapi', schema_view, name='openapi-schema'), + path('api/docs', swagger_view, name='swagger-ui'), + path('api/redoc', redoc_view, name='redoc'), ] diff --git a/requirements.txt b/requirements.txt index d20366a..4d6bb4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,3 +47,5 @@ style==1.1.0 text-unidecode==1.3 update==0.0.1 urllib3==2.2.1 +drf-spectacular==0.28.0 +PyYAML==6.0.2