diff --git a/bugsink/__init__.py b/bugsink/__init__.py index 86cfd0e..8bc6789 100644 --- a/bugsink/__init__.py +++ b/bugsink/__init__.py @@ -1,5 +1,6 @@ from django.db.backends.signals import connection_created from django.contrib.auth.management.commands.createsuperuser import Command as CreateSuperUserCommand +from drf_spectacular.extensions import OpenApiAuthenticationExtension def set_pragmas(sender, connection, **kwargs): @@ -40,3 +41,16 @@ def _get_input_message(self, field, default=None): unpatched_get_input_message = CreateSuperUserCommand._get_input_message CreateSuperUserCommand._get_input_message = _get_input_message + + +class BearerTokenAuthenticationExtension(OpenApiAuthenticationExtension): + # Will be auto-discovered b/c in __init__.py and subclass of OpenApiAuthenticationExtension + target_class = 'bugsink.authentication.BearerTokenAuthentication' + name = 'BearerAuth' + + def get_security_definition(self, auto_schema): + return { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'token', + } diff --git a/bugsink/settings/default.py b/bugsink/settings/default.py index 01d983c..0bcf3c4 100644 --- a/bugsink/settings/default.py +++ b/bugsink/settings/default.py @@ -67,6 +67,8 @@ INSTALLED_APPS = [ 'tailwind', # As currently set up, this is also needed in production (templatetags) 'admin_auto_filters', 'rest_framework', + 'drf_spectacular', + 'drf_spectacular_sidecar', # this brings the swagger-ui ] REST_FRAMEWORK = { @@ -86,6 +88,22 @@ REST_FRAMEWORK = { "DEFAULT_PARSER_CLASSES": [ "rest_framework.parsers.JSONParser", ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Bugsink', + 'DESCRIPTION': 'Bugsink API Documentation', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, # keep the docs clean and not document the docs endpoint itself. + + "SECURITY": [ + {"bearerAuth": []} + ], + "ENUM_NAME_OVERRIDES": { + "TeamVisibilityEnum": ["joinable", "discoverable", "hidden"], + "ProjectVisibilityEnum": ["joinable", "discoverable", "team_members"], + }, } BUGSINK_APPS = [ diff --git a/bugsink/urls.py b/bugsink/urls.py index 976ea81..2a65174 100644 --- a/bugsink/urls.py +++ b/bugsink/urls.py @@ -6,6 +6,7 @@ from django.contrib.auth import views as auth_views from django.views.generic import RedirectView, TemplateView from rest_framework import routers +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from alerts.views import debug_email as debug_alerts_email from users.views import debug_email as debug_users_email @@ -60,6 +61,8 @@ urlpatterns = [ path("users/", include("users.urls")), path("api/canonical/0/", include((api_router.urls, "api"), namespace="api")), + path("api/canonical/0/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/canonical/0/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), # these are sentry-cli endpoint for uploading; they're unrelated to e.g. the ingestion API. # the /api/0/ is just a hard prefix (for the ingest API, that position indicates the project id, but here it's just diff --git a/events/api_views.py b/events/api_views.py index 3da556d..7295695 100644 --- a/events/api_views.py +++ b/events/api_views.py @@ -1,6 +1,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets from rest_framework.exceptions import ValidationError +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from bugsink.utils import assert_ from bugsink.api_pagination import AscDescCursorPagination @@ -38,6 +39,28 @@ class EventViewSet(AtomicRequestMixin, viewsets.ReadOnlyModelViewSet): return queryset.filter(issue=query_params["issue"]) + @extend_schema( + parameters=[ + OpenApiParameter( + name="issue", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=True, + description="Filter events by issue UUID (required).", + ), + OpenApiParameter( + name="order", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + enum=["asc", "desc"], + description="Sort order of digest_order (default: desc).", + ), + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def get_object(self): """ DRF's get_object(), but we intentionally bypass filter_queryset for detail routes to keep PK lookups diff --git a/issues/api_views.py b/issues/api_views.py index ecebc38..6661f19 100644 --- a/issues/api_views.py +++ b/issues/api_views.py @@ -2,6 +2,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets from rest_framework.pagination import CursorPagination from rest_framework.exceptions import ValidationError +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from bugsink.api_mixins import AtomicRequestMixin @@ -69,6 +70,36 @@ class IssueViewSet(AtomicRequestMixin, viewsets.ReadOnlyModelViewSet): def get_queryset(self): return self.queryset + @extend_schema( + parameters=[ + OpenApiParameter( + name="project", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=True, + description="Filter issues by project id (required).", + ), + OpenApiParameter( + name="sort", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + enum=["digest_order", "last_seen"], + description="Sort mode (default: digest_order).", + ), + OpenApiParameter( + name="order", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + enum=["asc", "desc"], + description="Sort order (default: asc).", + ), + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) if self.action != "list": diff --git a/projects/api_views.py b/projects/api_views.py index 86a6235..37bab3d 100644 --- a/projects/api_views.py +++ b/projects/api_views.py @@ -1,5 +1,6 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from bugsink.api_pagination import AscDescCursorPagination from bugsink.api_mixins import ExpandViewSetMixin, AtomicRequestMixin @@ -33,6 +34,20 @@ class ProjectViewSet(AtomicRequestMixin, ExpandViewSetMixin, viewsets.ModelViewS http_method_names = ["get", "post", "patch", "head", "options"] pagination_class = ProjectPagination + @extend_schema( + parameters=[ + OpenApiParameter( + name="team", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=False, + description="Optional filter by team UUID.", + ), + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def filter_queryset(self, queryset): if self.action != "list": return queryset diff --git a/releases/api_views.py b/releases/api_views.py index 6648d7f..6a9e160 100644 --- a/releases/api_views.py +++ b/releases/api_views.py @@ -1,5 +1,6 @@ from rest_framework import viewsets from rest_framework.exceptions import ValidationError +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from bugsink.api_pagination import AscDescCursorPagination from bugsink.api_mixins import AtomicRequestMixin @@ -28,6 +29,20 @@ class ReleaseViewSet(AtomicRequestMixin, viewsets.ModelViewSet): http_method_names = ["get", "post", "head", "options"] pagination_class = ReleasePagination + @extend_schema( + parameters=[ + OpenApiParameter( + name="project", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=True, + description="Filter releases by project id (required).", + ), + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) if self.action != "list": diff --git a/requirements.txt b/requirements.txt index 869d355..d02beb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ fastjsonschema==2.21.* verbose_csrf_middleware==1.0.* ecma426>=0.2.0 djangorestframework==3.16.* +drf-spectacular[sidecar]