Add Swagger using drf-spectacular

See #146

DRF 3.16 and Django 5.2 are not in drf-spectacular's published
list of supported but here's some sources that give reason to believe
they are supported _in practice_:

* https://github.com/tfranzel/drf-spectacular/issues/1417
* https://github.com/tfranzel/drf-spectacular/issues/1414
This commit is contained in:
Klaas van Schelven
2025-09-12 11:32:16 +02:00
parent 9ad66d7b50
commit a4e84fa0a3
8 changed files with 120 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
from django.db.backends.signals import connection_created from django.db.backends.signals import connection_created
from django.contrib.auth.management.commands.createsuperuser import Command as CreateSuperUserCommand from django.contrib.auth.management.commands.createsuperuser import Command as CreateSuperUserCommand
from drf_spectacular.extensions import OpenApiAuthenticationExtension
def set_pragmas(sender, connection, **kwargs): 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 unpatched_get_input_message = CreateSuperUserCommand._get_input_message
CreateSuperUserCommand._get_input_message = _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',
}

View File

@@ -67,6 +67,8 @@ INSTALLED_APPS = [
'tailwind', # As currently set up, this is also needed in production (templatetags) 'tailwind', # As currently set up, this is also needed in production (templatetags)
'admin_auto_filters', 'admin_auto_filters',
'rest_framework', 'rest_framework',
'drf_spectacular',
'drf_spectacular_sidecar', # this brings the swagger-ui
] ]
REST_FRAMEWORK = { REST_FRAMEWORK = {
@@ -86,6 +88,22 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser", "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 = [ BUGSINK_APPS = [

View File

@@ -6,6 +6,7 @@ from django.contrib.auth import views as auth_views
from django.views.generic import RedirectView, TemplateView from django.views.generic import RedirectView, TemplateView
from rest_framework import routers from rest_framework import routers
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from alerts.views import debug_email as debug_alerts_email from alerts.views import debug_email as debug_alerts_email
from users.views import debug_email as debug_users_email from users.views import debug_email as debug_users_email
@@ -60,6 +61,8 @@ urlpatterns = [
path("users/", include("users.urls")), path("users/", include("users.urls")),
path("api/canonical/0/", include((api_router.urls, "api"), namespace="api")), 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. # 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 # the /api/0/ is just a hard prefix (for the ingest API, that position indicates the project id, but here it's just

View File

@@ -1,6 +1,7 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from bugsink.utils import assert_ from bugsink.utils import assert_
from bugsink.api_pagination import AscDescCursorPagination from bugsink.api_pagination import AscDescCursorPagination
@@ -38,6 +39,28 @@ class EventViewSet(AtomicRequestMixin, viewsets.ReadOnlyModelViewSet):
return queryset.filter(issue=query_params["issue"]) 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): def get_object(self):
""" """
DRF's get_object(), but we intentionally bypass filter_queryset for detail routes to keep PK lookups DRF's get_object(), but we intentionally bypass filter_queryset for detail routes to keep PK lookups

View File

@@ -2,6 +2,7 @@ from django.shortcuts import get_object_or_404
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.pagination import CursorPagination from rest_framework.pagination import CursorPagination
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from bugsink.api_mixins import AtomicRequestMixin from bugsink.api_mixins import AtomicRequestMixin
@@ -69,6 +70,36 @@ class IssueViewSet(AtomicRequestMixin, viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.queryset 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): def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
if self.action != "list": if self.action != "list":

View File

@@ -1,5 +1,6 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import viewsets from rest_framework import viewsets
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from bugsink.api_pagination import AscDescCursorPagination from bugsink.api_pagination import AscDescCursorPagination
from bugsink.api_mixins import ExpandViewSetMixin, AtomicRequestMixin 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"] http_method_names = ["get", "post", "patch", "head", "options"]
pagination_class = ProjectPagination 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): def filter_queryset(self, queryset):
if self.action != "list": if self.action != "list":
return queryset return queryset

View File

@@ -1,5 +1,6 @@
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from bugsink.api_pagination import AscDescCursorPagination from bugsink.api_pagination import AscDescCursorPagination
from bugsink.api_mixins import AtomicRequestMixin from bugsink.api_mixins import AtomicRequestMixin
@@ -28,6 +29,20 @@ class ReleaseViewSet(AtomicRequestMixin, viewsets.ModelViewSet):
http_method_names = ["get", "post", "head", "options"] http_method_names = ["get", "post", "head", "options"]
pagination_class = ReleasePagination 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): def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
if self.action != "list": if self.action != "list":

View File

@@ -17,3 +17,4 @@ fastjsonschema==2.21.*
verbose_csrf_middleware==1.0.* verbose_csrf_middleware==1.0.*
ecma426>=0.2.0 ecma426>=0.2.0
djangorestframework==3.16.* djangorestframework==3.16.*
drf-spectacular[sidecar]