diff --git a/README.md b/README.md index 929330d..6aa3d77 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,138 @@ print(serializer.data) } ``` +## Serializer Method Fields + +If you want support for nested `expand`, `omit` and `fields` query args in `SerializerMethodField` fields, use `FlexSerializerMethodField`. This can be usefull for serializing iterables that are not related to your original model. For example: + +```python +from rest_framework import serializers +from rest_flex_fields.serializers import FlexFieldsModelSerializer, FlexFieldsSerializerMixin +from rest_flex_fields.fields import FlexSerializerMethodField + + +class EventSerializer(FlexFieldsSerializerMixin, serializers.Serializer): + name = serializers.CharField() + city = serializers.CharField() + tickets = serializers.CharField() + + class Meta: + expandable_fields = { + "city": serializers.SerializerMethodField + } + + def get_city(self, obj): + return { "name": obj["city"] } + + +class CountrySerializer(FlexFieldsModelSerializer): + + class Meta: + model = Country + fields = ['name'] + expandable_fields = { + "events": FlexSerializerMethodField + } + + def get_events(self, obj, fields, expand, omit): + events = get_event_list(country=obj) + return EventSerializer(events, many=True, expand=expand, omit=omit, fields=fields).data + +``` + +Default expand `GET /countries?expand=events`: + +```json +[ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air", + "city": "Wacken", + "tickets": "www.example.com/wacken" + }, + { + "name": "Full Force", + "city": "Grafenhainichen", + "tickets": "www.example.com/full_force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection", + "city": "Viveiro", + "tickets": "www.example.com/resurrection" + } + ] + } +] +``` + +You can then use query args to filter the `events` fields. `GET /country_events?expand=events&fields=name,events.name`: + +```json +[ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air" + }, + { + "name": "Full Force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection" + } + ] + } +] +``` + +`omit` and `expand` also work, `GET /country_events?expand=events,events.city&omit=events.name`: + +```json +[ + { + "name": "Germany", + "events": [ + { + "city": { + "name": "Wacken" + }, + "tickets": "www.example.com/wacken" + }, + { + "city": { + "name": "Grafenhainichen" + }, + "tickets": "www.example.com/full_force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "city": { + "name": "Viveiro" + }, + "tickets": "www.example.com/resurrection" + } + ] + } +] +``` + # Serializer Options Dynamic field options can be passed in the following ways: @@ -484,9 +616,9 @@ class PersonSerializer(FlexFieldsModelSerializer): Parameter names and wildcard values can be configured within a Django setting, named `REST_FLEX_FIELDS`. | Option | Description | Default | -|-------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|-----------------| +| ----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | --------------- | | EXPAND_PARAM | The name of the parameter with the fields to be expanded | `"expand"` | -| MAXIMUM_EXPANSION_DEPTH | The max allowed expansion depth. By default it's unlimited. Expanding `state.towns` would equal a depth of 2 | `None` | +| MAXIMUM_EXPANSION_DEPTH | The max allowed expansion depth. By default it's unlimited. Expanding `state.towns` would equal a depth of 2 | `None` | | FIELDS_PARAM | The name of the parameter with the fields to be included (others will be omitted) | `"fields"` | | OMIT_PARAM | The name of the parameter with the fields to be omitted | `"omit"` | | RECURSIVE_EXPANSION_PERMITTED | If `False`, an exception is raised when a recursive pattern is found | `True` | @@ -504,8 +636,7 @@ A `maximum_expansion_depth` integer property can be set on a serializer class. `recursive_expansion_permitted` boolean property can be set on a serializer class. -Both settings raise `serializers.ValidationError` when conditions are met but exceptions can be customized by overriding the `recursive_expansion_not_permitted` and `expansion_depth_exceeded` methods. - +Both settings raise `serializers.ValidationError` when conditions are met but exceptions can be customized by overriding the `recursive_expansion_not_permitted` and `expansion_depth_exceeded` methods. ## Serializer Introspection @@ -584,6 +715,10 @@ It will automatically call `select_related` and `prefetch_related` on the curren # Changelog +## Unreleased + +- Adds `FlexSerializerMethodField`. + ## 1.0.2 (March 2023) - Adds control over whether recursive expansions are allowed and allows setting the max expansion depth. Thanks @andruten! diff --git a/rest_flex_fields/fields.py b/rest_flex_fields/fields.py new file mode 100644 index 0000000..7c577b8 --- /dev/null +++ b/rest_flex_fields/fields.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +class FlexSerializerMethodField(serializers.SerializerMethodField): + def __init__(self, method_name=None, **kwargs): + self.expand = kwargs.pop("expand", []) + self.fields = kwargs.pop("fields", []) + self.omit = kwargs.pop("omit", []) + super().__init__(method_name, **kwargs) + + def to_representation(self, value): + method = getattr(self.parent, self.method_name) + return method(value, self.fields, self.expand, self.omit) diff --git a/rest_flex_fields/serializers.py b/rest_flex_fields/serializers.py index f5d4179..02857f9 100644 --- a/rest_flex_fields/serializers.py +++ b/rest_flex_fields/serializers.py @@ -13,6 +13,7 @@ RECURSIVE_EXPANSION_PERMITTED, split_levels, ) +from .fields import FlexSerializerMethodField class FlexFieldsSerializerMixin(object): @@ -132,8 +133,9 @@ def _make_expanded_field_serializer( if issubclass(serializer_class, serializers.Serializer): settings["context"] = self.context - if issubclass(serializer_class, FlexFieldsSerializerMixin): - settings["parent"] = self + if issubclass(serializer_class, (FlexFieldsSerializerMixin, FlexSerializerMethodField)): + if issubclass(serializer_class, FlexFieldsSerializerMixin): + settings["parent"] = self if name in nested_expand: settings[EXPAND_PARAM] = nested_expand[name] diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 0000000..98a121e --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,130 @@ +from django.test import TestCase +from django.urls import reverse +from tests.testapp.models import Country + + +class TestFlexSerializerMethodField(TestCase): + @classmethod + def setUpClass(cls) -> None: + Country.objects.create(name="Germany") + Country.objects.create(name="Spain") + return super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + Country.objects.all().delete() + return super().tearDownClass() + + def setUp(self) -> None: + self.url = reverse("country-events-list") + return super().setUp() + + def test_base_request(self): + r = self.client.get(self.url) + + self.assertListEqual( + r.json(), + [ + { + "name": "Germany", + }, + { + "name": "Spain", + } + ] + ) + + def test_omit_arg(self): + r = self.client.get(self.url+"?omit=name,events.tickets&expand=events") + self.assertListEqual( + r.json(), + [ + { + "events": [ + { + "name": "Wacken Open Air", + "city": "Wacken", + }, + { + "name": "Full Force", + "city": "Grafenhainichen", + } + ] + }, + { + "events": [ + { + "name": "Resurrection", + "city": "Viveiro", + } + ] + } + ] + ) + + def test_fields_arg(self): + r = self.client.get(self.url+"?fields=name,events.name&expand=events") + self.assertListEqual( + r.json(), + [ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air" + }, + { + "name": "Full Force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection" + } + ] + } + ] + ) + + def test_expand_arg(self): + r = self.client.get(self.url+"?expand=events.city") + self.assertListEqual( + r.json(), + [ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air", + "city": { + "name": "Wacken", + }, + "tickets": "www.example.com/wacken" + }, + { + "name": "Full Force", + "city": { + "name": "Grafenhainichen", + }, + "tickets": "www.example.com/full_force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection", + "city": { + "name": "Viveiro", + }, + "tickets": "www.example.com/resurrection" + } + ] + } + ] + ) + diff --git a/tests/test_views.py b/tests/test_views.py index 2e77026..b0e8723 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,5 +1,4 @@ from http import HTTPStatus -from pprint import pprint from unittest.mock import patch from django.contrib.contenttypes.models import ContentType diff --git a/tests/testapp/models.py b/tests/testapp/models.py index df01d9d..780b31d 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -33,4 +33,8 @@ class TaggedItem(models.Model): tag = models.SlugField() content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') \ No newline at end of file + content_object = GenericForeignKey('content_type', 'object_id') + + +class Country(models.Model): + name = models.CharField(max_length=200) diff --git a/tests/testapp/serializers.py b/tests/testapp/serializers.py index 0f1a2e6..bea427e 100644 --- a/tests/testapp/serializers.py +++ b/tests/testapp/serializers.py @@ -1,9 +1,10 @@ from rest_framework import serializers from rest_framework.relations import PrimaryKeyRelatedField -from rest_flex_fields import FlexFieldsModelSerializer -from tests.testapp.models import Pet, PetStore, Person, Company, TaggedItem - +from rest_flex_fields.serializers import FlexFieldsModelSerializer, FlexFieldsSerializerMixin +from rest_flex_fields.fields import FlexSerializerMethodField +from tests.testapp.models import Pet, PetStore, Person, Company, TaggedItem, Country +from tests.testapp.utils import get_event_list class CompanySerializer(FlexFieldsModelSerializer): class Meta: @@ -24,6 +25,34 @@ class Meta: fields = ["id", "name"] +class EventSerializer(FlexFieldsSerializerMixin, serializers.Serializer): + name = serializers.CharField() + city = serializers.CharField() + tickets = serializers.CharField() + + class Meta: + expandable_fields = { + "city": serializers.SerializerMethodField + } + + def get_city(self, obj): + return { "name": obj["city"] } + + +class CountrySerializer(FlexFieldsModelSerializer): + + class Meta: + model = Country + fields = ['name'] + expandable_fields = { + "events": FlexSerializerMethodField + } + + def get_events(self, obj, fields, expand, omit): + events = get_event_list(country=obj) + return EventSerializer(events, many=True, expand=expand, omit=omit, fields=fields).data + + class PetSerializer(FlexFieldsModelSerializer): owner = serializers.PrimaryKeyRelatedField(queryset=Person.objects.all()) sold_from = serializers.PrimaryKeyRelatedField( @@ -45,7 +74,11 @@ def get_diet(self, obj): if obj.name == "Garfield": return "homemade lasanga" return "pet food" - + + def get_info(self, obj, expand, fields, omit): + { + "is_vegetarian": obj.diet + } class TaggedItemSerializer(FlexFieldsModelSerializer): content_object = PrimaryKeyRelatedField(read_only=True) diff --git a/tests/testapp/utils.py b/tests/testapp/utils.py new file mode 100644 index 0000000..37dd68d --- /dev/null +++ b/tests/testapp/utils.py @@ -0,0 +1,25 @@ +EVENTS = { + "Germany": [ + { + "name": "Wacken Open Air", + "city": "Wacken", + "tickets": "www.example.com/wacken" + }, + { + "name": "Full Force", + "city": "Grafenhainichen", + "tickets": "www.example.com/full_force" + } + ], + "Spain": [ + { + "name": "Resurrection", + "city": "Viveiro", + "tickets": "www.example.com/resurrection" + } + ] +} + + +def get_event_list(country): + return EVENTS[country.name] diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 71ec443..f746348 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -1,8 +1,9 @@ -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ModelViewSet, GenericViewSet +from rest_framework.mixins import ListModelMixin from rest_flex_fields import FlexFieldsModelViewSet -from tests.testapp.models import Pet, TaggedItem -from tests.testapp.serializers import PetSerializer, TaggedItemSerializer +from tests.testapp.models import Pet, TaggedItem, Country +from tests.testapp.serializers import PetSerializer, TaggedItemSerializer, CountrySerializer class PetViewSet(FlexFieldsModelViewSet): @@ -18,3 +19,8 @@ class PetViewSet(FlexFieldsModelViewSet): class TaggedItemViewSet(ModelViewSet): serializer_class = TaggedItemSerializer queryset = TaggedItem.objects.all() + + +class CountryEventsViewset(GenericViewSet, ListModelMixin): + serializer_class = CountrySerializer + queryset = Country.objects.all() diff --git a/tests/urls.py b/tests/urls.py index 998b0aa..5c56562 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,10 +1,11 @@ from django.conf.urls import url, include from rest_framework import routers -from tests.testapp.views import PetViewSet, TaggedItemViewSet +from tests.testapp.views import PetViewSet, TaggedItemViewSet, CountryEventsViewset # Standard viewsets router = routers.DefaultRouter() router.register(r"pets", PetViewSet, basename="pet") router.register(r"tagged-items", TaggedItemViewSet, basename="tagged-item") +router.register(r"country_events", CountryEventsViewset, basename="country-events") urlpatterns = [url(r"^", include(router.urls))]