diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fca922..218f4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for Python 3.11 - Drop support for Python 3.6 +### Added +- In the Django REST framework layer, callables in a spec are now automatically called and passed the `request` object ([#76](https://github.com/dabapps/django-readers/pull/76)) +- Support for generating a Django REST framework serializer from a spec, and for annotating custom pairs in a spec with their output field types. This enables automatic schema generation. ([#76](https://github.com/dabapps/django-readers/pull/76)) + ## [2.0.0] - 2022-07-19 ### Changed diff --git a/django_readers/rest_framework.py b/django_readers/rest_framework.py index 1d9482e..bb60fac 100644 --- a/django_readers/rest_framework.py +++ b/django_readers/rest_framework.py @@ -1,6 +1,11 @@ +from copy import deepcopy from django.core.exceptions import ImproperlyConfigured from django.utils.functional import cached_property from django_readers import specs +from django_readers.utils import SpecVisitor +from functools import wraps +from rest_framework import serializers +from rest_framework.utils import model_meta class ProjectionSerializer: @@ -25,8 +30,12 @@ def get_spec(self): raise ImproperlyConfigured("SpecMixin requires spec or get_spec") return self.spec + def _preprocess_spec(self, spec): + visitor = _CallWithRequestVisitor(self.request) + return visitor.visit(spec) + def get_reader_pair(self): - return specs.process(self.get_spec()) + return specs.process(self._preprocess_spec(self.get_spec())) @cached_property def reader_pair(self): @@ -46,3 +55,201 @@ def get_queryset(self): def get_serializer_class(self): return ProjectionSerializer + + +class _CallWithRequestVisitor(SpecVisitor): + def __init__(self, request): + self.request = request + + def visit_callable(self, fn): + return fn(self.request) + + +class _SpecToSerializerVisitor(SpecVisitor): + def __init__(self, model, name): + self.model = model + self.name = name + self.field_builder = serializers.ModelSerializer() + self.info = model_meta.get_field_info(model) + self.fields = {} + + def _lowercase_with_underscores_to_capitalized_words(self, string): + return "".join(part.title() for part in string.split("_")) + + def _prepare_field(self, field): + # 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) + 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 hasattr(item, "out"): + return item.out + if isinstance(item, tuple) and hasattr(item[1], "out"): + return item[1].out + return None + + def visit_str(self, item): + return self.visit_dict_item_str(item, item) + + def visit_dict_item_str(self, key, value): + # This is a model field name. First, check if the + # field has been explicitly overridden + if hasattr(value, "out"): + field = self._prepare_field(value.out) + self.fields[str(key)] = field + return key, field + + # 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) + return key, value + + def visit_dict_item_list(self, key, value): + # This is a relationship, so we recurse and create + # a nested serializer to represent it + rel_info = self.info.relations[key] + capfirst = self._lowercase_with_underscores_to_capitalized_words(key) + child_serializer = serializer_class_for_spec( + f"{self.name}{capfirst}", + rel_info.related_model, + value, + ) + self.fields[key] = child_serializer( + read_only=True, + many=rel_info.to_many, + ) + return key, value + + def visit_dict_item_dict(self, key, value): + # This is an aliased relationship, so we basically + # do the same as the previous case, but handled + # slightly differently to set the `source` correctly + relationship_name, relationship_spec = next(iter(value.items())) + rel_info = self.info.relations[relationship_name] + capfirst = self._lowercase_with_underscores_to_capitalized_words(key) + child_serializer = serializer_class_for_spec( + f"{self.name}{capfirst}", + rel_info.related_model, + relationship_spec, + ) + self.fields[key] = child_serializer( + read_only=True, + many=rel_info.to_many, + source=relationship_name, + ) + return key, value + + def visit_dict_item_tuple(self, key, value): + # This is a producer pair. + out = self._get_out_value(value) + if out: + field = self._prepare_field(out) + self.fields[key] = field + else: + # Fallback case: we don't know what field type to use + self.fields[key] = serializers.ReadOnlyField() + 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) + if out: + # `out` is a dictionary mapping field names to Fields + for name, field in out.items(): + field = self._prepare_field(field) + 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. + return item + + visit_callable = visit_tuple + + +def serializer_class_for_spec(name_prefix, model, spec): + visitor = _SpecToSerializerVisitor(model, name_prefix) + visitor.visit(spec) + + return type( + f"{name_prefix}Serializer", + (serializers.Serializer,), + { + "Meta": type("Meta", (), {"model": model}), + **visitor.fields, + }, + ) + + +def serializer_class_for_view(view): + name_prefix = view.__class__.__name__ + if name_prefix.endswith("View"): + name_prefix = name_prefix[:-4] + + if hasattr(view, "model"): + model = view.model + else: + model = getattr(getattr(view, "queryset", None), "model", None) + + if not model: + raise ImproperlyConfigured( + "View class must have either a 'queryset' or 'model' attribute" + ) + + return serializer_class_for_spec(name_prefix, model, view.spec) + + +class PairWithOutAttribute(tuple): + out = None + + +class StringWithOutAttribute(str): + out = 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") + + class ShiftableDecorator: + def __call__(self, item): + if callable(item): + + @wraps(item) + def wrapper(*args, **kwargs): + result = item(*args, **kwargs) + return self(result) + + wrapper.out = field_or_dict + return wrapper + else: + if isinstance(item, str): + item = StringWithOutAttribute(item) + if isinstance(item, tuple): + item = PairWithOutAttribute(item) + item.out = field_or_dict + return item + + def __rrshift__(self, other): + return self(other) + + return ShiftableDecorator() diff --git a/django_readers/utils.py b/django_readers/utils.py index 0536162..6953d50 100644 --- a/django_readers/utils.py +++ b/django_readers/utils.py @@ -48,3 +48,61 @@ def queries_disabled(pair): prepare, project = pair decorator = zen_queries.queries_disabled() if zen_queries else lambda fn: fn return decorator(prepare), decorator(project) + + +class SpecVisitor: + def visit(self, spec): + return [self.visit_item(item) for item in spec] + + def visit_item(self, item): + if isinstance(item, str): + return self.visit_str(item) + if isinstance(item, dict): + return self.visit_dict(item) + if isinstance(item, tuple): + return self.visit_tuple(item) + if callable(item): + return self.visit_callable(item) + raise ValueError(f"Unexpected item in spec: {item}") + + def visit_str(self, item): + return item + + def visit_dict(self, item): + return dict(self.visit_dict_item(key, value) for key, value in item.items()) + + def visit_tuple(self, item): + return item + + def visit_callable(self, item): + return item + + def visit_dict_item(self, key, value): + if isinstance(value, str): + return self.visit_dict_item_str(key, value) + if isinstance(value, list): + return self.visit_dict_item_list(key, value) + if isinstance(value, dict): + if len(value) != 1: + raise ValueError("Aliased relationship spec must contain only one key") + return self.visit_dict_item_dict(key, value) + if isinstance(value, tuple): + return self.visit_dict_item_tuple(key, value) + if callable(value): + return self.visit_dict_item_callable(key, value) + raise ValueError(f"Unexpected item in spec: {key}, {value}") + + def visit_dict_item_str(self, key, value): + return key, self.visit_str(value) + + def visit_dict_item_list(self, key, value): + return key, self.visit(value) + + def visit_dict_item_dict(self, key, value): + return key, self.visit_dict(value) + + def visit_dict_item_tuple(self, key, value): + return key, self.visit_tuple(value) + + def visit_dict_item_callable(self, key, value): + return key, self.visit_callable(value) diff --git a/docs/cookbook.md b/docs/cookbook.md index af1cbc4..d1b8b72 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -219,3 +219,43 @@ spec = [ }, ] ``` + +## Specify output fields for Django REST framework introspection + +The [Django REST framework layer](/reference/rest-framework/) supports generation of serializer classes based on a spec, for the purpose of introspection and schema generation. For custom behaviour like pairs and higher-order functions, the output field type must be explicitly specified. Below is an example covering a couple of use cases. See [the docs on serializer and schema generation](/reference/rest-framework/#serializer-and-schema-generation) for full details. + +```python +from django_readers.rest_framework import out, serializer_class_for_view, SpecMixin +from rest_framework.views import RetrieveAPIView +from rest_framework import serializers + + +class SpecSchema(AutoSchema): + def get_serializer(self, path, method): + return serializer_class_for_view(self.view)() + + +@out(serializers.BooleanField()) +def request_user_is_author(request): + def produce(instance): + return instance.author.email == request.user.email + + return ( + qs.auto_prefetch_relationship( + "author", + prepare_related_queryset=qs.include_fields("email"), + ), + produce, + ) + + +class BookDetailView(SpecMixin, RetrieveAPIView): + schema = SpecSchema() + queryset = Book.objects.all() + spec = [ + "id", + "title", + {"request_user_is_author": request_user_is_author}, + {"format": pairs.field_display("format") >> out(serializers.CharField())}, + ] +``` diff --git a/docs/index.md b/docs/index.md index d8eb53d..a79810b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ pip install django-readers ## What is django-readers? -`django-readers` is both a **small library** (less than 500 lines of Python) and a **collection of recommended patterns** for structuring your code. It is intended to help with code that performs _reads_: querying your database and presenting the data to the user. It can be used with views that render HTML templates as well as [Django REST framework](https://www.django-rest-framework.org/) API views, and indeed anywhere else in your project where data is retrieved from the database. +`django-readers` is both a **small library** and a **collection of recommended patterns** for structuring your code. It is intended to help with code that performs _reads_: querying your database and presenting the data to the user. It can be used with views that render HTML templates as well as [Django REST framework](https://www.django-rest-framework.org/) API views, and indeed anywhere else in your project where data is retrieved from the database. It lets you: diff --git a/docs/reference/rest-framework.md b/docs/reference/rest-framework.md new file mode 100644 index 0000000..91b8a0b --- /dev/null +++ b/docs/reference/rest-framework.md @@ -0,0 +1,187 @@ +If you use [django-rest-framework](https://www.django-rest-framework.org/), `django-readers` provides a view mixin that allows you to easily use a [spec](specs.md) to serialize your data: + +```python +from django_readers.rest_framework import SpecMixin + + +class AuthorDetailView(SpecMixin, RetrieveAPIView): + queryset = Author.objects.all() + spec = [ + "id", + "name", + { + "book_set": [ + "id", + "title", + "publication_date", + ] + }, + ] +``` + +This mixin is only suitable for use with `RetrieveAPIView` or `ListAPIView`. It doesn't use a "real" Serializer: it calls the `project` function that is the result of processing your `spec`. We recommend using separate views for endpoints that modify data, rather than combining these concerns into a single endpoint. + +If your endpoint needs to provide dynamic behaviour based on the incoming request, you have two options: + +1. `SpecMixin` supports one extra feature in its `spec` property: any callable in the spec (in place of a pair) will automatically be called at request time, and passed a single argument: the `request` object. This callable can return a pair of functions that close over the request. +2. You can override the `get_spec` method and return your spec. Note that this approach is not compatible with schema generation (see below). + +If you need to override `get_queryset`, you must call `self.prepare` on the queryset that you return: + +```python hl_lines="9" +class GoogleyAuthorListView(SpecMixin, ListAPIView): + + spec = [ + ..., + ] + + def get_queryset(self): + queryset = Author.objects.filter(email__contains="google.com") + return self.prepare(queryset) +``` + +## Serializer and schema generation + +The `django-readers` `SpecMixin` bypasses the usual Django REST framework approach of serializing data using a `Serializer` in favour of using a projector function to generate a mapping of names to values based on a model instance. This is simpler, faster and less memory intensive than using a `Serializer`. However, some parts of REST framework rely on serializers to do their work; in particular, the [schema generation mechanism](https://www.django-rest-framework.org/api-guide/schemas/) introspects serializer fields to generate an OpenAPI schema. + +To enable schema generation (and any other requirements for a "real" serializer) for `django-readers` views, two utility functions are provided: `serializer_class_for_spec` and `serializer_class_for_view`. + +Note that the serializers created by these functions are not actually used at request time: they are useful only for introspection. + +## `rest_framework.serializer_class_for_spec(name_prefix, model, spec)` {: #serializer-class-for-spec} + +This takes: + +* A name prefix for the resulting top-level serializer class. This should be `CapitalizedWords`, the word `Serializer` will be appended. +* A model class +* A spec + +It returns a serializer class representing the spec, with nested serializers representing the relationships. + +For named fields (strings in the spec) it uses the same mechanism as `ModelSerializer` to introspect the model and select appropriate serializer fields for each model field. For custom pairs, the field must be specified explicitly: [see below](#customising-serializer-fields) for details. + +```python hl_lines="11" +spec = [ + "name", + { + "book_set": [ + "id", + "title", + ] + }, +] + +cls = serializer_class_for_spec("Publisher", Publisher, spec) +print(cls()) +``` + +This prints something like: + +``` +PublisherSerializer(): + name = CharField(max_length=100, read_only=True) + book_set = PublisherBookSetSerializer(many=True, read_only=True): + id = IntegerField(label='ID', read_only=True) + title = CharField(allow_null=True, max_length=100, read_only=True, required=False) +``` + +## `rest_framework.serializer_class_for_view(view)` {: #serializer-class-for-view} + +This higher-level function generates a serializer given a view instance. + +* The name of the serializer is inferred from the view name (the word `View` is removed). +* The model class is taken from either the `queryset` attribute of the view, or (if `get_queryset` has been overridden), explicitly from the `Model` attribute. +* The spec is taken from the `spec` attribute of the view. + +This can be used to create a simple [custom `AutoSchema` subclass](https://www.django-rest-framework.org/api-guide/schemas/#autoschema) to support schema generation: + +```python +class SpecSchema(AutoSchema): + def get_serializer(self, path, method): + return serializer_class_for_view(self.view)() +``` + +Note that `django-readers` does not provide this view mixin: it is trivial to create and add to your project, and it is likely that it will need to be customised to your specific needs. + +## Customising serializer fields + +For named fields (strings) in a spec, `serializer_class_for_spec` uses the same mechanism as `ModelSerializer` to infer the field types for the model. However, for custom pairs in a spec, the serializer field to use must be specified explicitly. `django-readers` provides a utility called `out` which can be used in two ways: as a decorator, or inline in a spec. + +### `out` as a decorator + +For custom pair functions, you can use `out` as a decorator, and provide a serializer field instance to use in the serializer: + +```python hl_lines="4" +from django_readers.rest_framework import out + + +@out(serializers.CharField()) +def hello_world(): + return qs.noop, lambda instance: "Hello world" + + +class SomeView(SpecMixin, RetrieveAPIView): + queryset = SomeModel.objects.all() + spec = [ + ..., + {"hello": hello_world()}, + ..., + ] +``` + +You can also decorate only the producer function of a pair: + +```python hl_lines="1" +@out(serializers.CharField()) +def produce_hello_world(instance): + return "Hello world" + +hello_world = qs.noop, produce_hello_world + +class SomeView(SpecMixin, RetrieveAPIView): + queryset = SomeModel.objects.all() + spec = [ + ..., + {"hello": hello_world}, + ..., + ] +``` + +For projector pairs, `out` should be given a dictionary mapping the field names in the returned dictionary to their output field types: + +```python hl_lines="1-6" +@out( + { + "hello": serializers.CharField(), + "answer": serializers.IntegerField(), + } +) +def hello_world(): + return qs.noop, lambda instance: {"hello": "world", "answer": 42} + +class SomeView(SpecMixin, RetrieveAPIView): + queryset = SomeModel.objects.all() + spec = [ + ..., + hello_world(), + ..., + ] +``` + +Again, you can also decorate only the projector function of the pair. + +### `out` used inline in a spec + +For cases where a reusable pair function (eg from the `django_readers.pairs` module) is being used in a spec, it may be inconvenient to wrap this in a function just to apply the `out` decorator. In this case, `out` supports a special "[DSL](https://en.wikipedia.org/wiki/Domain-specific_language)-ish" syntax, by overriding the `>>` operator to allow it to easily be used inline in a spec: + +```python hl_lines="5" +class SomeView(SpecMixin, RetrieveAPIView): + queryset = SomeModel.objects.all() + spec = [ + ..., + {"genre": pairs.field_display("genre") >> out(serializers.CharField())}, + ..., + ] +``` + +This mechanism can also be used to override the output field type for an autogenerated field (a string). diff --git a/docs/reference/specmixin.md b/docs/reference/specmixin.md deleted file mode 100644 index 4efbd0f..0000000 --- a/docs/reference/specmixin.md +++ /dev/null @@ -1,38 +0,0 @@ -If you use [django-rest-framework](https://www.django-rest-framework.org/), `django-readers` provides a view mixin that allows you to easily use a [spec](specs.md) to serialize your data: - -```python -from django_readers.rest_framework import SpecMixin - - -class AuthorDetailView(SpecMixin, RetrieveAPIView): - queryset = Author.objects.all() - spec = [ - "id", - "name", - { - "book_set": [ - "id", - "title", - "publication_date", - ] - }, - ] -``` - -This mixin is only suitable for use with `RetrieveAPIView` or `ListAPIView`. It doesn't use a "real" Serializer: it calls the `project` function that is the result of processing your `spec`. We recommend using separate views for endpoints that modify data, rather than combining these concerns into a single endpoint. - -If your endpoint needs to provide dynamic behaviour based on the user making the request, you should instead override the `get_spec` method and return your spec. - -If you need to override `get_queryset`, you must call `self.prepare` on the queryset that you return: - -```python hl_lines="9" -class GoogleyAuthorListView(SpecMixin, ListAPIView): - - spec = [ - ..., - ] - - def get_queryset(self): - queryset = Author.objects.filter(email__contains="google.com") - return self.prepare(queryset) -``` diff --git a/mkdocs.yml b/mkdocs.yml index 2ff7c81..29ec4e2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,7 +40,7 @@ nav: - Projectors: 'reference/projectors.md' - Pairs: 'reference/pairs.md' - Specs: 'reference/specs.md' - - SpecMixin: 'reference/specmixin.md' + - REST framework: 'reference/rest-framework.md' - Community: - Changelog: 'community/changelog.md' - License: 'community/license.md' diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index c5f882f..e04c21f 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -1,8 +1,17 @@ +from django.core.exceptions import ImproperlyConfigured from django.test import TestCase -from django_readers.rest_framework import SpecMixin +from django_readers import pairs, qs +from django_readers.rest_framework import ( + out, + serializer_class_for_spec, + serializer_class_for_view, + SpecMixin, +) +from rest_framework import serializers from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.test import APIRequestFactory from tests.models import Category, Group, Owner, Widget +from textwrap import dedent class WidgetListView(SpecMixin, ListAPIView): @@ -39,6 +48,9 @@ class CategoryDetailView(SpecMixin, RetrieveAPIView): ] +upper_name = pairs.field("name", transform_value=lambda value: value.upper()) + + class RESTFrameworkTestCase(TestCase): def test_list(self): Widget.objects.create( @@ -97,3 +109,481 @@ def test_detail(self): ], }, ) + + +class SpecToSerializerClassTestCase(TestCase): + def test_basic_spec(self): + spec = ["name"] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_nested_spec(self): + spec = [ + "name", + { + "widget_set": [ + "name", + { + "owner": [ + "name", + ] + }, + ] + }, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + widget_set = CategoryWidgetSetSerializer(many=True, read_only=True): + name = CharField(allow_null=True, max_length=100, read_only=True, required=False) + owner = CategoryWidgetSetOwnerSerializer(read_only=True): + name = CharField(max_length=100, read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_all_relationship_types(self): + spec = [ + "name", + { + "group": [ + "name", + ] + }, + { + "widget_set": [ + "name", + { + "category_set": [ + "name", + ] + }, + { + "thing": [ + "name", + { + "related_widget": { + "widget": [ + "name", + ] + } + }, + ] + }, + ] + }, + ] + + cls = serializer_class_for_spec("Owner", Owner, spec) + + expected = dedent( + """\ + OwnerSerializer(): + name = CharField(max_length=100, read_only=True) + group = OwnerGroupSerializer(read_only=True): + name = CharField(max_length=100, read_only=True) + widget_set = OwnerWidgetSetSerializer(many=True, read_only=True): + name = CharField(allow_null=True, max_length=100, read_only=True, required=False) + category_set = OwnerWidgetSetCategorySetSerializer(many=True, read_only=True): + name = CharField(max_length=100, read_only=True) + thing = OwnerWidgetSetThingSerializer(read_only=True): + name = CharField(max_length=100, read_only=True) + related_widget = OwnerWidgetSetThingRelatedWidgetSerializer(read_only=True, source='widget'): + name = CharField(allow_null=True, max_length=100, read_only=True, required=False)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_duplicate_relationship_naming(self): + spec = [ + {"widget_set": ["name"]}, + {"set_of_widgets": {"widget_set": ["name"]}}, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + widget_set = CategoryWidgetSetSerializer(many=True, read_only=True): + name = CharField(allow_null=True, max_length=100, read_only=True, required=False) + set_of_widgets = CategorySetOfWidgetsSerializer(many=True, read_only=True, source='widget_set'): + name = CharField(allow_null=True, max_length=100, read_only=True, required=False)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_serializer_class_for_view(self): + class CategoryListView(SpecMixin, ListAPIView): + queryset = Category.objects.all() + spec = [ + "name", + { + "widget_set": [ + "name", + { + "owner": [ + "name", + ] + }, + ] + }, + ] + + cls = serializer_class_for_view(CategoryListView()) + + expected = dedent( + """\ + CategoryListSerializer(): + name = CharField(max_length=100, read_only=True) + widget_set = CategoryListWidgetSetSerializer(many=True, read_only=True): + name = CharField(allow_null=True, max_length=100, read_only=True, required=False) + owner = CategoryListWidgetSetOwnerSerializer(read_only=True): + name = CharField(max_length=100, read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_exception_raised_if_model_missing(self): + class SomeListView(SpecMixin, ListAPIView): + spec = ["name"] + + with self.assertRaises(ImproperlyConfigured): + serializer_class_for_view(SomeListView()) + + +class OutputFieldTestCase(TestCase): + def test_output_field(self): + spec = [ + "name", + {"upper_name": out(serializers.CharField())(upper_name)}, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + upper_name = CharField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_output_field_decorator(self): + @out(serializers.CharField()) + def hello(): + return lambda qs: qs, lambda _: "Hello" + + spec = [ + "name", + {"hello": hello()}, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + hello = CharField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_output_field_decorator_producer(self): + @out(serializers.CharField()) + def produce_hello(_): + return "Hello" + + hello = qs.noop, produce_hello + + spec = [ + "name", + {"hello": hello}, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + hello = CharField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_out_rrshift(self): + spec = [ + "name", + {"upper_name": upper_name >> out(serializers.CharField())}, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + upper_name = CharField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_field_name_override(self): + spec = [ + "name" >> out(serializers.IntegerField()), + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = IntegerField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_out_raises_with_field_class(self): + with self.assertRaises(TypeError): + out(serializers.CharField) + + def test_output_field_is_ignored_when_calling_view(self): + class WidgetListView(SpecMixin, ListAPIView): + queryset = Widget.objects.all() + spec = [ + "name", + {"upper_name": out(serializers.CharField())(upper_name)}, + { + "owned_by": { + "owner": [ + "name", + {"upper_name": out(serializers.CharField())(upper_name)}, + ] + } + }, + ] + + Widget.objects.create( + name="test widget", + owner=Owner.objects.create(name="test owner"), + ) + + request = APIRequestFactory().get("/") + view = WidgetListView.as_view() + + response = view(request) + + self.assertEqual( + response.data, + [ + { + "name": "test widget", + "upper_name": "TEST WIDGET", + "owned_by": { + "name": "test owner", + "upper_name": "TEST OWNER", + }, + } + ], + ) + + def test_out_with_projector_pair(self): + @out( + { + "upper_name": serializers.CharField(), + "name_length": serializers.IntegerField(), + } + ) + def upper_name_and_name_length(): + def project(instance): + return { + "upper_name": instance.name.upper(), + "name_length": len(instance.name), + } + + return qs.include_fields("name"), project + + spec = [ + "name", + upper_name_and_name_length(), + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + upper_name = CharField(read_only=True) + name_length = IntegerField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + def test_out_with_projector_pair_projector_only(self): + @out( + { + "upper_name": serializers.CharField(), + "name_length": serializers.IntegerField(), + } + ) + def project(instance): + return { + "upper_name": instance.name.upper(), + "name_length": len(instance.name), + } + + upper_name_and_name_length = qs.include_fields("name"), project + + spec = [ + "name", + upper_name_and_name_length, + ] + + cls = serializer_class_for_spec("Category", Category, spec) + + expected = dedent( + """\ + CategorySerializer(): + name = CharField(max_length=100, read_only=True) + upper_name = CharField(read_only=True) + name_length = IntegerField(read_only=True)""" + ) + self.assertEqual(repr(cls()), expected) + + +class CallableTestCase(TestCase): + def test_call_producer_pair_with_request(self): + def user_name(request): + return lambda qs: qs, lambda _: request.user.name + + class WidgetListView(SpecMixin, ListAPIView): + queryset = Widget.objects.all() + spec = [ + "name", + { + "user_name": out(serializers.CharField())(user_name), + }, + { + "owned_by": { + "owner": [ + "name", + {"user_name": out(serializers.CharField())(user_name)}, + ] + } + }, + ] + + Widget.objects.create( + name="test widget", + owner=Owner.objects.create(name="test owner"), + ) + + class FakeUser: + def __init__(self, name): + self.name = name + self.is_active = True + + request = APIRequestFactory().get("/") + request.user = FakeUser(name="Test User") + view = WidgetListView.as_view() + + response = view(request) + + self.assertEqual( + response.data, + [ + { + "name": "test widget", + "user_name": "Test User", + "owned_by": { + "name": "test owner", + "user_name": "Test User", + }, + } + ], + ) + + def test_undecorated_producer_pair_does_not_cause_error(self): + def user_name(request): + return lambda qs: qs, lambda _: request.user.name + + spec = [ + "name", + {"user_name": user_name}, + ] + + serializer_class_for_spec("Widget", Widget, spec) + + def test_call_projector_pair_with_request(self): + def user_name_and_id(request): + return lambda qs: qs, lambda _: { + "user_name": request.user.name, + "user_id": request.user.id, + } + + class WidgetListView(SpecMixin, ListAPIView): + queryset = Widget.objects.all() + spec = [ + "name", + user_name_and_id, + { + "owned_by": { + "owner": [ + "name", + user_name_and_id, + ] + } + }, + ] + + Widget.objects.create( + name="test widget", + owner=Owner.objects.create(name="test owner"), + ) + + class FakeUser: + def __init__(self, id, name): + self.name = name + self.id = id + self.is_active = True + + request = APIRequestFactory().get("/") + request.user = FakeUser(id="12345", name="Test User") + view = WidgetListView.as_view() + + response = view(request) + + self.assertEqual( + response.data, + [ + { + "name": "test widget", + "user_name": "Test User", + "user_id": "12345", + "owned_by": { + "name": "test owner", + "user_name": "Test User", + "user_id": "12345", + }, + } + ], + ) + + def test_undecorated_projector_pair_does_not_cause_error(self): + def user_name_and_id(request): + return lambda qs: qs, lambda _: { + "user_name": request.user.name, + "user_id": request.user.id, + } + + spec = [ + "name", + user_name_and_id, + ] + + serializer_class_for_spec("Widget", Widget, spec)