diff --git a/bugsink/api_mixins.py b/bugsink/api_mixins.py new file mode 100644 index 0000000..c240f57 --- /dev/null +++ b/bugsink/api_mixins.py @@ -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) diff --git a/projects/api_views.py b/projects/api_views.py index 111bf0a..41a9c6a 100644 --- a/projects/api_views.py +++ b/projects/api_views.py @@ -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= filter diff --git a/projects/serializers.py b/projects/serializers.py index 0bdbb20..0dcceca 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -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) diff --git a/projects/test_api.py b/projects/test_api.py index 7bf9366..c452567 100644 --- a/projects/test_api.py +++ b/projects/test_api.py @@ -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."]}, + )