diff --git a/CHANGELOG.md b/CHANGELOG.md index fe63988..51e0221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added support for overriding field kwargs for autogenerated serializer fields via `out` mechanism ([#99](https://github.com/dabapps/django-readers/pull/99)). + ## [2.2.0] - 2024-01-12 ### Added diff --git a/django_readers/rest_framework.py b/django_readers/rest_framework.py index ec73c2b..9431a25 100644 --- a/django_readers/rest_framework.py +++ b/django_readers/rest_framework.py @@ -15,7 +15,12 @@ def add_annotation(obj, key, value): def get_annotation(obj, key): - return getattr(obj, "_readers_annotation", {}).get(key) + # Either the item itself or (if this is a pair) just the + # producer/projector function may have been decorated + if value := getattr(obj, "_readers_annotation", {}).get(key): + return value + if isinstance(obj, tuple): + return get_annotation(obj[1], key) class ProjectionSerializer: @@ -86,24 +91,16 @@ def __init__(self, model, name): def _lowercase_with_underscores_to_capitalized_words(self, string): return "".join(part.title() for part in string.split("_")) - def _prepare_field(self, field): + def _prepare_field(self, field, kwargs=None): # We copy the field so its _creation_counter is correct and # it appears in the right order in the resulting serializer. # We also force it to be read_only field = deepcopy(field) + if kwargs: + field._kwargs.update(kwargs) field._kwargs["read_only"] = True return field - def _get_out_value(self, item): - # Either the item itself or (if this is a pair) just the - # producer/projector function may have been decorated - if out := get_annotation(item, "out"): - return out - if isinstance(item, tuple): - if out := get_annotation(item[1], "out"): - return out - return None - def visit_str(self, item): return self.visit_dict_item_str(item, item) @@ -111,22 +108,26 @@ def visit_dict_item_str(self, key, value): # This is a model field name. First, check if the # field has been explicitly overridden if out := get_annotation(value, "out"): - field = self._prepare_field(out) - self.fields[str(key)] = field - return key, field + field = self._prepare_field(out, kwargs=get_annotation(value, "kwargs")) - # No explicit override, so we can use ModelSerializer - # machinery to figure out which field type to use - field_class, field_kwargs = self.field_builder.build_field( - value, - self.info, - self.model, - 0, - ) - if key != value: - field_kwargs["source"] = value - field_kwargs.setdefault("read_only", True) - self.fields[key] = field_class(**field_kwargs) + else: + # No explicit override, so we can use ModelSerializer + # machinery to figure out which field type to use + field_class, field_kwargs = self.field_builder.build_field( + value, + self.info, + self.model, + 0, + ) + if key != value: + field_kwargs["source"] = value + field_kwargs.setdefault("read_only", True) + + if kwargs := get_annotation(value, "kwargs"): + field_kwargs.update(kwargs) + field = field_class(**field_kwargs) + + self.fields[str(key)] = field return key, value def _get_child_serializer_kwargs(self, rel_info): @@ -183,24 +184,26 @@ def visit_dict_item_dict(self, key, value): def visit_dict_item_tuple(self, key, value): # This is a producer pair. - out = self._get_out_value(value) + out = get_annotation(value, "out") + kwargs = get_annotation(value, "kwargs") or {} if out: - field = self._prepare_field(out) + field = self._prepare_field(out, kwargs) self.fields[key] = field else: # Fallback case: we don't know what field type to use - self.fields[key] = serializers.ReadOnlyField() + self.fields[key] = serializers.ReadOnlyField(**kwargs) return key, value visit_dict_item_callable = visit_dict_item_tuple def visit_tuple(self, item): # This is a projector pair. - out = self._get_out_value(item) + out = get_annotation(item, "out") + kwargs = get_annotation(item, "kwargs") or {} if out: # `out` is a dictionary mapping field names to Fields for name, field in out.items(): - field = self._prepare_field(field) + field = self._prepare_field(field, kwargs) self.fields[name] = field # There is no fallback case because we have no way of knowing the shape # of the returned dictionary, so the schema will be unavoidably incorrect. @@ -249,14 +252,20 @@ class StringWithAnnotation(str): _readers_annotation = None -def out(field_or_dict): - if isinstance(field_or_dict, dict): - if not all( - isinstance(item, serializers.Field) for item in field_or_dict.values() - ): - raise TypeError("Each value must be an instance of Field") - elif not isinstance(field_or_dict, serializers.Field): - raise TypeError("Must be an instance of Field") +def out(*args, **kwargs): + if args: + if len(args) != 1: + raise TypeError("Provide a single field or dictionary of fields") + field_or_dict = args[0] + if isinstance(field_or_dict, dict): + if not all( + isinstance(item, serializers.Field) for item in field_or_dict.values() + ): + raise TypeError("Each value must be an instance of Field") + elif not isinstance(field_or_dict, serializers.Field): + raise TypeError("Must be an instance of Field") + else: + field_or_dict = None class ShiftableDecorator: def __call__(self, item): @@ -268,14 +277,17 @@ def wrapper(*args, **kwargs): return self(result) add_annotation(wrapper, "out", field_or_dict) + add_annotation(wrapper, "kwargs", kwargs) return wrapper else: if isinstance(item, str): item = StringWithAnnotation(item) add_annotation(item, "out", field_or_dict) + add_annotation(item, "kwargs", kwargs) if isinstance(item, tuple): item = PairWithAnnotation(item) add_annotation(item, "out", field_or_dict) + add_annotation(item, "kwargs", kwargs) return item def __rrshift__(self, other): diff --git a/docs/reference/rest-framework.md b/docs/reference/rest-framework.md index 097a0af..e10289d 100644 --- a/docs/reference/rest-framework.md +++ b/docs/reference/rest-framework.md @@ -185,3 +185,17 @@ class SomeView(SpecMixin, RetrieveAPIView): ``` This mechanism can also be used to override the output field type for an autogenerated field (a string). + +### Overriding default behaviour + +Rather than providing a serializer field instance to `out`, you can optionally provide keyword arguments that will be used when constructing the _default_ serializer field that would otherwise be generated by model field introspection. This is particularly useful when using the generated serializers to create a schema, because schema generation libraries often use `label` and `help_text` to add metadata to fields in the schema. For example: + +```python hl_lines="5" +class SomeView(SpecMixin, RetrieveAPIView): + queryset = SomeModel.objects.all() + spec = [ + ..., + "title" >> out(label="The title of the object") + ..., + ] +``` diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 546f43d..41c492d 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -416,6 +416,34 @@ def test_field_name_override(self): ) self.assertEqual(repr(cls()), expected) + def test_out_kwargs(self): + @out(serializers.CharField(), label="Hello label") + def produce_hello(_): + return "Hello" + + hello = qs.noop, produce_hello + + spec = [ + "name" >> out(help_text="Help for name"), + {"aliased_name": "name" >> out(label="Label for aliased name")}, + {"upper_name": out(help_text="Help for upper name")(upper_name)}, + # This is a bit redundant (kwargs could just be passed to the field + # directly) but should still work. + {"hello": hello}, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(help_text='Help for name', max_length=100, read_only=True) + aliased_name = CharField(label='Label for aliased name', max_length=100, read_only=True, source='name') + upper_name = ReadOnlyField(help_text='Help for upper name') + hello = CharField(label='Hello label', read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + def test_out_raises_with_field_class(self): with self.assertRaises(TypeError): out(serializers.CharField)