API: support expand=...

(implemented only for project.team for now, but in a generic way
This commit is contained in:
Klaas van Schelven
2025-09-11 17:38:42 +02:00
parent 7bf906a8fd
commit 1c0745f24f
4 changed files with 113 additions and 4 deletions

42
bugsink/api_mixins.py Normal file
View File

@@ -0,0 +1,42 @@
from rest_framework.exceptions import ValidationError
class ExpandableSerializerMixin:
expandable_fields = {}
def __init__(self, *args, **kwargs):
self._expand = set(kwargs.pop("expand", []))
super().__init__(*args, **kwargs)
def to_representation(self, instance):
data = super().to_representation(instance)
for field, serializer_cls in self.expandable_fields.items():
if field in self._expand:
data[field] = serializer_cls(getattr(instance, field)).data
return data
class ExpandViewSetMixin:
"""
Mixin for ViewSets that support ?expand=...
Requires the serializer class to define expandable_fields.
"""
def get_serializer(self, *args, **kwargs):
expand = self.request.query_params.getlist("expand")
if expand:
if len(expand) == 1 and "," in expand[0]:
expand = expand[0].split(",")
serializer_cls = self.get_serializer_class()
expandable = getattr(serializer_cls, "expandable_fields", None)
if expandable is None:
raise ValidationError({"expand": ["Expansions are not supported on this endpoint."]})
invalid = [f for f in expand if f not in expandable]
if invalid:
raise ValidationError({"expand": [f"Unknown field: {name}" for name in invalid]})
kwargs["expand"] = expand
return super().get_serializer(*args, **kwargs)

View File

@@ -1,6 +1,8 @@
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from bugsink.api_pagination import AscDescCursorPagination
from bugsink.api_mixins import ExpandViewSetMixin
from .models import Project
from .serializers import (
@@ -18,7 +20,7 @@ class ProjectPagination(AscDescCursorPagination):
default_direction = "asc"
class ProjectViewSet(viewsets.ModelViewSet):
class ProjectViewSet(ExpandViewSetMixin, viewsets.ModelViewSet):
"""
/api/canonical/0/projects/
GET /projects/ → list ordered by name ASC, hides soft-deleted, optional ?team=<uuid> filter

View File

@@ -2,6 +2,9 @@ from rest_framework import serializers
from bugsink.api_fields import EnumLowercaseChoiceField
from teams.models import Team
from bugsink.api_mixins import ExpandableSerializerMixin
from teams.serializers import TeamDetailSerializer
from .models import Project, ProjectVisibility
@@ -28,7 +31,8 @@ class ProjectListSerializer(serializers.ModelSerializer):
]
class ProjectDetailSerializer(serializers.ModelSerializer):
class ProjectDetailSerializer(ExpandableSerializerMixin, serializers.ModelSerializer):
expandable_fields = {"team": TeamDetailSerializer}
visibility = EnumLowercaseChoiceField(ProjectVisibility)
dsn = serializers.CharField(read_only=True)

View File

@@ -1,4 +1,4 @@
from django.test import TestCase
from django.test import TestCase as DjangoTestCase
from django.urls import reverse
from rest_framework.test import APIClient
@@ -7,7 +7,7 @@ from teams.models import Team
from projects.models import Project
class ProjectApiTests(TestCase):
class ProjectApiTests(DjangoTestCase):
def setUp(self):
self.client = APIClient()
token = AuthToken.objects.create()
@@ -75,3 +75,64 @@ class ProjectApiTests(TestCase):
p = Project.objects.create(team=self.team, name="Temp")
r = self.client.delete(reverse("api:project-detail", args=[p.id]))
self.assertEqual(r.status_code, 405)
class ExpansionTests(DjangoTestCase):
"""
Expansion tests are exercised via ProjectViewSet, but the intent is to validate the
generic ExpandableSerializerMixin infrastructure.
"""
def setUp(self):
self.client = APIClient()
token = AuthToken.objects.create()
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}")
self.team = Team.objects.create(name="T")
self.project = Project.objects.create(name="P", team=self.team)
def _get(self, expand=None):
url = reverse("api:project-detail", args=[self.project.id])
qp = {"expand": expand} if expand else {}
return self.client.get(url, qp)
def test_default_no_expand(self):
r = self._get()
self.assertEqual(r.status_code, 200)
data = r.json()
# team is just rendered as a reference, not expanded
self.assertEqual(data["team"], str(self.team.id))
def test_with_valid_expand(self):
r = self._get("team")
self.assertEqual(r.status_code, 200)
data = r.json()
# team is fully expanded into object
self.assertEqual(data["team"]["id"], str(self.team.id))
self.assertEqual(data["team"]["name"], self.team.name)
def test_with_invalid_expand(self):
r = self._get("not_a_field")
self.assertEqual(r.status_code, 400)
self.assertEqual(
r.json(),
{"expand": ["Unknown field: not_a_field"]},
)
def test_with_comma_separated_expands(self):
# only 'team' is valid, 'not_a_field' should trigger 400
r = self._get("team,not_a_field")
self.assertEqual(r.status_code, 400)
self.assertEqual(
r.json(),
{"expand": ["Unknown field: not_a_field"]},
)
def test_expand_rejected_when_not_supported(self):
# ProjectListSerializer does not support expand
url = reverse("api:project-list")
r = self.client.get(url, {"expand": "team"})
self.assertEqual(r.status_code, 400)
self.assertEqual(
r.json(),
{"expand": ["Expansions are not supported on this endpoint."]},
)