Repository: oxan/djangorestframework-dataclasses

Issue:
Allow specifying serializer field configuration using dataclass field metadata?
As @intgr mentioned in #30, it might be nice to support specifying the serializer field configuration using dataclass field metadata.

> I've thought about this a bit. It would be helpful and much cleaner if one could pass custom field kwargs overrides using dataclass `field(metadata=...)`, e.g.:
> 
> ```python
> @dataclass
> class Example:
>     string: str = field(metadata={"drf_field_args": {"allow_blank": True}})
>     lst: List[str] = field(metadata={"drf_field_args": {"child": serializers.CharField(allow_blank=True)}})
> ```
> 
> Does this seem like a mechanism you want to support @oxan?


> Or perhaps a metadata key that allows overriding the field as a whole
> 
> ```python
> @dataclass
> class Example:
>     string: str = field(metadata={"drf_field": serializers.CharField(allow_blank=True)})
>     lst: List[str] = field(metadata={
>         "drf_field": serializers.ListField(child=serializers.CharField(allow_blank=True))
>     })
> ```


Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 1f928dc..89e172b 100644
--- a/README.rst
+++ b/README.rst
@@ -111,6 +111,26 @@ to dataclass serializers as well.
 Customize field generation
 --------------------------
 
+Besides overriding fields by declaring them explicitly on the serializer, you can also override the generated field
+using metadata on the dataclass field. Currently, two keys are recognized in this dictionary:
+
+* ``serializer_field`` can be used to replace the auto-generated field with a user-supplied one. Should contain an
+  instance of a field, not a field type.
+
+* ``serializer_kwargs`` can be used to specify arbitrary additional keyword arguments for the generated field.
+
+.. code:: Python
+
+    @dataclasses.dataclass
+    class Person:
+        email: str = dataclasses.field(metadata={'serializer_field': fields.EmailField()})
+        age: int = dataclasses.field(metadata={'serializer_kwargs': {'min_value': 0}})
+
+    # the autogenerated serializer will be equal to
+    class PersonSerializer(Serializer):
+        email = fields.EmailField()
+        age = fields.IntegerField(min_value=0)
+
 To customize the generated fields, the ``DataclassSerializer`` accepts the following options in the ``Meta`` subclass.
 All options have the same behaviour as the identical options in ``ModelSerializer``.
 
diff --git a/rest_framework_dataclasses/serializers.py b/rest_framework_dataclasses/serializers.py
index 529e59c..28207db 100644
--- a/rest_framework_dataclasses/serializers.py
+++ b/rest_framework_dataclasses/serializers.py
@@ -180,6 +180,11 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
         # the serializer instance.
         declared_fields = copy.deepcopy(self._declared_fields)
 
+        # Copy fields declared in metadata, for the same reason.
+        metadata_fields = {field.name: copy.deepcopy(field.metadata['serializer_field'])
+                           for field in self.dataclass_definition.fields.values()
+                           if 'serializer_field' in field.metadata}
+
         # Determine all fields that should be included on the serializer.
         field_names = self.get_field_names()
 
@@ -190,15 +195,16 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
         fields = OrderedDict()
 
         for field_name in field_names:
-            # If the field is explicitly declared on the class then use that.
-            if field_name in declared_fields:
+            # If the field is explicitly declared, either on the serializer or in the field metadata, then use that.
+            if field_name in declared_fields or field_name in metadata_fields:
                 assert field_name not in extra_kwargs, (
                     "Cannot both declare the field '{field_name}' and include it in '{serializer_class}' "
                     "`extra_kwargs` or `read_only_fields` option. Move all options to the field declaration."
                     .format(field_name=field_name, serializer_class=self.__class__.__name__)
                 )
 
-                fields[field_name] = declared_fields[field_name]
+                # Prefer the field declared on the serializer above the one declared in field metadata.
+                fields[field_name] = declared_fields.get(field_name, metadata_fields.get(field_name, None))
                 continue
 
             extra_field_kwargs = extra_kwargs.get(field_name, {})
@@ -318,10 +324,14 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
 
         # Determine the serializer field class and keyword arguments.
         if source in self.dataclass_definition.fields:
-            default_value = self.dataclass_definition.fields[source].default
-            default_type = type(default_value) if default_value is not None else None
+            field = self.dataclass_definition.fields[source]
+            default_type = type(field.default) if field.default is not None else None
             type_info = field_utils.get_type_info(self.dataclass_definition.field_types[source], default_type)
             field_class, field_kwargs = self.build_typed_field(source, type_info, extra_kwargs)
+
+            # Include extra kwargs defined in the field metadata
+            if 'serializer_kwargs' in field.metadata:
+                field_kwargs = self.include_extra_kwargs(field_kwargs, field.metadata['serializer_kwargs'])
         elif hasattr(self.dataclass_definition.dataclass_type, source):
             field_class, field_kwargs = self.build_property_field(source)
         else:


Test patch:
diff --git a/tests/test_functional.py b/tests/test_functional.py
index 25b3a5e..b851e82 100644
--- a/tests/test_functional.py
+++ b/tests/test_functional.py
@@ -24,7 +24,13 @@ class Gender(enum.Enum):
 class Pet:
     animal: Literal['cat', 'dog']
     name: str
-    weight: typing.Optional[decimal.Decimal] = None
+    weight: typing.Optional[decimal.Decimal] = \
+        dataclasses.field(default=None, metadata={'serializer_kwargs': {'max_digits': 4, 'decimal_places': 1}})
+
+
+class PetSerializer(DataclassSerializer):
+    class Meta:
+        dataclass = Pet
 
 
 @dataclasses.dataclass
@@ -37,7 +43,8 @@ class Person:
     length: typing.Optional[decimal.Decimal] = None
     pets: typing.Optional[typing.List[Pet]] = None
     birth_date: typing.Optional[datetime.date] = None
-    favorite_pet: typing.Optional[Pet] = None
+    favorite_pet: typing.Optional[Pet] = \
+        dataclasses.field(default=None, metadata={'serializer_field': PetSerializer(allow_null=True)})
     movie_ratings: typing.Optional[typing.Dict[str, int]] = None
 
     def age(self) -> int:
@@ -45,17 +52,9 @@ class Person:
         return datetime.date(2020, 1, 1).year - self.birth_date.year if self.birth_date else None
 
 
-class PetSerializer(DataclassSerializer):
-    weight = fields.DecimalField(required=False, allow_null=True, max_digits=4, decimal_places=1)
-
-    class Meta:
-        dataclass = Pet
-
-
 class PersonSerializer(DataclassSerializer):
     full_name = fields.CharField(source='name')
     email = fields.EmailField()
-    favorite_pet = PetSerializer(allow_null=True)
     slug = fields.SlugField(source='name', read_only=True)
 
     class Meta:
diff --git a/tests/test_serializers.py b/tests/test_serializers.py
index ff109a2..89a7736 100644
--- a/tests/test_serializers.py
+++ b/tests/test_serializers.py
@@ -216,7 +216,13 @@ class SerializerTest(TestCase):
         check(dictoptional, {'field': {'K': {'value': 'A'}}}, dictoptional({'K': simple('A')}))
 
     def test_get_fields(self):
-        f = self.create_serializer(Person,
+        @dataclasses.dataclass
+        class TestPerson:
+            name: str
+            email: str = dataclasses.field(metadata={'serializer_field': fields.EmailField()})
+            birth_date: typing.Optional[datetime.date] = None
+
+        f = self.create_serializer(TestPerson,
                                    declared={'slug': fields.SlugField(source='name')},
                                    meta={'read_only_fields': ['birth_date'],
                                          'extra_kwargs': {'name': {'max_length': 15}}}).get_fields()
@@ -227,7 +233,7 @@ class SerializerTest(TestCase):
         self.assertTrue(f['name'].required)
         self.assertEqual(f['name'].max_length, 15)
 
-        self.assertIsInstance(f['length'], fields.IntegerField)
+        self.assertIsInstance(f['email'], fields.EmailField)
 
         self.assertIsInstance(f['birth_date'], fields.DateField)
         self.assertTrue(f['birth_date'].read_only)
@@ -236,7 +242,11 @@ class SerializerTest(TestCase):
         self.assertEqual(f['slug'].source, 'name')
 
         with self.assertRaises(AssertionError):
-            self.create_serializer(Person,
+            self.create_serializer(TestPerson,
+                                   meta={'read_only_fields': ['email']}).get_fields()
+
+        with self.assertRaises(AssertionError):
+            self.create_serializer(TestPerson,
                                    declared={'slug': fields.SlugField(source='name')},
                                    meta={'read_only_fields': ['slug']}).get_fields()
 
@@ -292,7 +302,16 @@ class SerializerTest(TestCase):
             self.create_serializer(Person, meta={'fields': ['name'], 'exclude': ['length']}).get_field_names()
 
     def test_create_field(self):
-        serializer = self.create_serializer(Person)
+        @dataclasses.dataclass
+        class TestPerson:
+            name: str
+            length: int = dataclasses.field(metadata={'serializer_kwargs': {'max_value': 200}})
+            species: types.Final = 'Human'
+
+            def age(self):
+                pass
+
+        serializer = self.create_serializer(TestPerson)
 
         # From a field
         name_field = serializer.create_field('name', {'max_length': 20, 'trim_whitespace': False})
@@ -300,6 +319,11 @@ class SerializerTest(TestCase):
         self.assertEqual(name_field.max_length, 20)
         self.assertEqual(name_field.trim_whitespace, False)
 
+        # Field with metadata arguments
+        length_field = serializer.create_field('length', {})
+        self.assertIsInstance(length_field, fields.IntegerField)
+        self.assertEqual(length_field.max_value, 200)
+
         # Function field
         age_field = serializer.create_field('age', {})
         self.assertIsInstance(age_field, fields.ReadOnlyField)
@@ -314,9 +338,6 @@ class SerializerTest(TestCase):
             unknown_field = serializer.create_field('aliased', {'source': 'nonexisting'})
 
         # Final field with a default value
-        dc = dataclasses.make_dataclass('Person', [('species', types.Final, dataclasses.field(default='Human'))])
-        serializer = self.create_serializer(dc)
-
         species_field = serializer.create_field('species', {})
         self.assertIsInstance(species_field, fields.CharField)
         self.assertTrue(species_field.read_only)