Skip to content

Commit

Permalink
Merge pull request #76 from dabapps/spec-to-serializer
Browse files Browse the repository at this point in the history
Spec-to-serializer
  • Loading branch information
j4mie authored Jan 13, 2023
2 parents d10f10c + a6839e1 commit 1059b80
Show file tree
Hide file tree
Showing 9 changed files with 990 additions and 42 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
209 changes: 208 additions & 1 deletion django_readers/rest_framework.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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):
Expand All @@ -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()
58 changes: 58 additions & 0 deletions django_readers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
40 changes: 40 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())},
]
```
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading

0 comments on commit 1059b80

Please sign in to comment.