From 211bd7207baed4d5e940ea6aa031ea5dde810905 Mon Sep 17 00:00:00 2001 From: Caio Fontes Date: Sun, 17 Sep 2023 10:18:08 -0300 Subject: [PATCH 1/5] feat: add docs and tests of how it should work --- README.md | 96 ++++++++++++++++++++++++++++-- rest_flex_fields/fields.py | 4 ++ tests/test_fields.py | 109 +++++++++++++++++++++++++++++++++++ tests/test_views.py | 1 - tests/testapp/models.py | 6 +- tests/testapp/serializers.py | 30 ++++++++-- tests/testapp/utils.py | 25 ++++++++ tests/testapp/views.py | 12 +++- tests/urls.py | 3 +- 9 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 rest_flex_fields/fields.py create mode 100644 tests/test_fields.py create mode 100644 tests/testapp/utils.py diff --git a/README.md b/README.md index 929330d..1db35fe 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,91 @@ 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(serializers.Serializer, FlexFieldsSerializerMixin): + class Meta: + model = Event + fields = ["name", "city", "tickets"] + +class CountrySerializer(FlexFieldsModelSerializer): + events = FlexSerializerMethodField() + + class Meta: + model = Country + fields = ['name', 'events'] + + def get_events(self, obj, expand, omit, fields): + events = get_event_list(country=obj) # get events from some external api or something like that + return EventSerializer(events, many=True, expand=expand, omit=omit, fields=fields).data +``` + +Default `GET /country_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?fields=name,events.name`: + +```json +[ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air" + }, + { + "name": "Full Force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection" + } + ] + } +] +``` + # Serializer Options Dynamic field options can be passed in the following ways: @@ -484,9 +569,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 +589,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 +668,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..27bd446 --- /dev/null +++ b/rest_flex_fields/fields.py @@ -0,0 +1,4 @@ +from rest_framework import serializers + +class FlexSerializerMethodField(serializers.SerializerMethodField): + ... diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 0000000..dd06b66 --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,109 @@ +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_filtering_events_fields(self): + r = self.client.get(self.url) + + self.assertListEqual( + r.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" + } + ] + } + ] + ) + + r = self.client.get(self.url+"?fields=name,events.name") + self.assertListEqual( + r.json(), + [ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air" + }, + { + "name": "Full Force" + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection" + } + ] + } + ] + ) + + r = self.client.get(self.url+"?omit=events.tickets") + self.assertListEqual( + r.json(), + [ + { + "name": "Germany", + "events": [ + { + "name": "Wacken Open Air", + "city": "Wacken", + }, + { + "name": "Full Force", + "city": "Grafenhainichen", + } + ] + }, + { + "name": "Spain", + "events": [ + { + "name": "Resurrection", + "city": "Viveiro", + } + ] + } + ] + ) 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..6c268fc 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,23 @@ class Meta: fields = ["id", "name"] +class EventSerializer(serializers.Serializer, FlexFieldsSerializerMixin): + class Meta: + fields = ["name", "city", "tickets"] + + +class CountrySerializer(FlexFieldsModelSerializer): + events = FlexSerializerMethodField() + + class Meta: + model = Country + fields = ['name', 'events'] + + def get_events(self, obj, expand, omit, fields): + 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 +63,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))] From 4a2c36ec6d2853bf5ec39207062c3f7083a8be2b Mon Sep 17 00:00:00 2001 From: Caio Fontes Date: Sun, 17 Sep 2023 10:47:10 -0300 Subject: [PATCH 2/5] fix: fix EventSerializer --- README.md | 8 ++++---- tests/testapp/serializers.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1db35fe..41e1234 100644 --- a/README.md +++ b/README.md @@ -439,10 +439,10 @@ from rest_flex_fields.serializers import FlexFieldsModelSerializer, FlexFieldsSe from rest_flex_fields.fields import FlexSerializerMethodField -class EventSerializer(serializers.Serializer, FlexFieldsSerializerMixin): - class Meta: - model = Event - fields = ["name", "city", "tickets"] +class EventSerializer(FlexFieldsSerializerMixin, serializers.Serializer): + name = serializers.CharField() + city = serializers.CharField() + tickets = serializers.CharField() class CountrySerializer(FlexFieldsModelSerializer): events = FlexSerializerMethodField() diff --git a/tests/testapp/serializers.py b/tests/testapp/serializers.py index 6c268fc..4e318e6 100644 --- a/tests/testapp/serializers.py +++ b/tests/testapp/serializers.py @@ -25,9 +25,10 @@ class Meta: fields = ["id", "name"] -class EventSerializer(serializers.Serializer, FlexFieldsSerializerMixin): - class Meta: - fields = ["name", "city", "tickets"] +class EventSerializer(FlexFieldsSerializerMixin, serializers.Serializer): + name = serializers.CharField() + city = serializers.CharField() + tickets = serializers.CharField() class CountrySerializer(FlexFieldsModelSerializer): From 0c49abbcb2cf2996d03b95b0a746261596a03ad2 Mon Sep 17 00:00:00 2001 From: Caio Fontes Date: Sun, 17 Sep 2023 11:59:59 -0300 Subject: [PATCH 3/5] chore: add expand param test --- tests/test_fields.py | 52 +++++++++++++++++++++++++++++++----- tests/testapp/serializers.py | 8 ++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index dd06b66..d9ccd04 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -19,7 +19,7 @@ def setUp(self) -> None: self.url = reverse("country-events-list") return super().setUp() - def test_filtering_events_fields(self): + def test_base_request(self): r = self.client.get(self.url) self.assertListEqual( @@ -53,6 +53,35 @@ def test_filtering_events_fields(self): ] ) + def test_omit_arg(self): + r = self.client.get(self.url+"?omit=name,events.tickets") + 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") self.assertListEqual( r.json(), @@ -79,20 +108,27 @@ def test_filtering_events_fields(self): ] ) - r = self.client.get(self.url+"?omit=events.tickets") + 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": "Wacken", + "city": { + "name": "Wacken", + }, + "tickets": "www.example.com/wacken" }, { "name": "Full Force", - "city": "Grafenhainichen", + "city": { + "name": "Grafenhainichen", + }, + "tickets": "www.example.com/full_force" } ] }, @@ -101,9 +137,13 @@ def test_filtering_events_fields(self): "events": [ { "name": "Resurrection", - "city": "Viveiro", + "city": { + "name": "Viveiro", + }, + "tickets": "www.example.com/resurrection" } ] } ] ) + diff --git a/tests/testapp/serializers.py b/tests/testapp/serializers.py index 4e318e6..1bdba25 100644 --- a/tests/testapp/serializers.py +++ b/tests/testapp/serializers.py @@ -30,6 +30,14 @@ class EventSerializer(FlexFieldsSerializerMixin, serializers.Serializer): city = serializers.CharField() tickets = serializers.CharField() + class Meta: + expandable_fields = { + "city": serializers.SerializerMethodField + } + + def get_city(self, value): + return { "name": value } + class CountrySerializer(FlexFieldsModelSerializer): events = FlexSerializerMethodField() From d84634fef08449921b740ff3a9874ffb983444f7 Mon Sep 17 00:00:00 2001 From: Caio Fontes Date: Sun, 17 Sep 2023 12:29:50 -0300 Subject: [PATCH 4/5] feat: implement behaviour of FlexSerializerMethodField --- rest_flex_fields/fields.py | 10 +++++++++- rest_flex_fields/serializers.py | 6 ++++-- tests/test_fields.py | 23 ++--------------------- tests/testapp/serializers.py | 12 +++++++----- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/rest_flex_fields/fields.py b/rest_flex_fields/fields.py index 27bd446..7c577b8 100644 --- a/rest_flex_fields/fields.py +++ b/rest_flex_fields/fields.py @@ -1,4 +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 index d9ccd04..98a121e 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -27,34 +27,15 @@ def test_base_request(self): [ { "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" - } - ] } ] ) def test_omit_arg(self): - r = self.client.get(self.url+"?omit=name,events.tickets") + r = self.client.get(self.url+"?omit=name,events.tickets&expand=events") self.assertListEqual( r.json(), [ @@ -82,7 +63,7 @@ def test_omit_arg(self): ) def test_fields_arg(self): - r = self.client.get(self.url+"?fields=name,events.name") + r = self.client.get(self.url+"?fields=name,events.name&expand=events") self.assertListEqual( r.json(), [ diff --git a/tests/testapp/serializers.py b/tests/testapp/serializers.py index 1bdba25..bea427e 100644 --- a/tests/testapp/serializers.py +++ b/tests/testapp/serializers.py @@ -35,18 +35,20 @@ class Meta: "city": serializers.SerializerMethodField } - def get_city(self, value): - return { "name": value } + def get_city(self, obj): + return { "name": obj["city"] } class CountrySerializer(FlexFieldsModelSerializer): - events = FlexSerializerMethodField() class Meta: model = Country - fields = ['name', 'events'] + fields = ['name'] + expandable_fields = { + "events": FlexSerializerMethodField + } - def get_events(self, obj, expand, omit, fields): + 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 From 8a93d5a65dbd98e669c0749f9699f3a74d14b4df Mon Sep 17 00:00:00 2001 From: Caio Fontes Date: Sun, 17 Sep 2023 12:33:32 -0300 Subject: [PATCH 5/5] chore: update doc --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 41e1234..6aa3d77 100644 --- a/README.md +++ b/README.md @@ -444,19 +444,31 @@ class EventSerializer(FlexFieldsSerializerMixin, serializers.Serializer): 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): - events = FlexSerializerMethodField() class Meta: model = Country - fields = ['name', 'events'] + fields = ['name'] + expandable_fields = { + "events": FlexSerializerMethodField + } - def get_events(self, obj, expand, omit, fields): - events = get_event_list(country=obj) # get events from some external api or something like that + 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 `GET /country_events`: +Default expand `GET /countries?expand=events`: ```json [ @@ -488,7 +500,7 @@ Default `GET /country_events`: ] ``` -You can then use query args to filter the `events` fields. `GET /country_events?fields=name,events.name`: +You can then use query args to filter the `events` fields. `GET /country_events?expand=events&fields=name,events.name`: ```json [ @@ -514,6 +526,41 @@ You can then use query args to filter the `events` fields. `GET /country_events? ] ``` +`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: