mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-19 19:49:44 -06:00
API: support expand=...
(implemented only for project.team for now, but in a generic way
This commit is contained in:
42
bugsink/api_mixins.py
Normal file
42
bugsink/api_mixins.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."]},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user