Skip to content

Instantly share code, notes, and snippets.

@egasimus
Last active January 5, 2021 08:55
Show Gist options
  • Save egasimus/6095421 to your computer and use it in GitHub Desktop.
Save egasimus/6095421 to your computer and use it in GitHub Desktop.
A few remarks on the Django REST Framework

A few remarks on the Django REST Framework

For a good while, I've been interested in the topic of automatically generating and routing views. For my last project, I built a haphazard implementation which ran using an elaborate scheme of mixins and decorators. A veritable monstrosity, it was - and the cognitive load of working with it wasted over a month of my time, allowing the project to slowly descend into limbo. I eventually ended up re-writing the heaps of class-based views by hand. Even though making good use of inheritance helped me tremendously, it was still a chore, and my interest in a tool that would save me some boilerplate code for standard CRUD apps remained.

When I first got a glimpse of the Django REST Framework, I was quite impressed by the browsable API; a quick look at the documentation got me hooked onto the concept of ViewSets and Routers. A few days ago, I decided to finally get my feet wet with the DRF and use it in my latest project. I honestly believed that it would be a piece of cake to build views that seamlessly handle user-facing pages as well as API interactions. I mean, how hard could it be? Just a matter of skinning the browsable API... right?

So here I am four days later, trying to implement the functionality that I need, in a way that works together with DRF and not against it. As always, I should've known better than expecting this endeavour not to take the better part of my week. :) But I'm making myself familiar with the inner workings of the DRF, and I definitely like what I see: a breath of fresh air, and another sophisticated, but highly necessary layer of abstraction on top of Django's solid foundation.

However, I find myself jumping through more hoops than I've apparently gotten used to. To me, Django itself has always been quite permissive in that aspect - under the hood, there's a minimal amount of magic, control flow is fairly easy to comprehend, customization hooks are well placed, and so on. My point is, when you get used to its sometimes idiosyncratic ways, Django is not jump-through-hoopy at all. (Except maybe forms - takes a while to grok them.) This is why I've listed below some of the things that I'm doing with DRF, along with the questions that they've posed. I am hoping that you could give me your opinions on those subjects. In return, though I hold no particularly high opinion of my work - other than I do not allow myself to write code outside of whatever constraints I have set for myself - I am hoping that my non-standard usage of DRF could perhaps provide you with needed architectural insights about how people are actually using the library, which would hopefully help shape the future of this promising project. This is why I would be grateful if you went over my code and shared your thoughts on the approaches that I have taken.

Forms

As I mentioned above, my initial goal was to add user-facing form views as an integral part of my ViewSets. Just today, I managed to implement it in a way that makes at least a little sense. I've mostly copied the code from django.views.generic.edit.FormMixin and ModelFormMixin - there were some little things which prevented me from just inheriting from those mixins. FormMixin, for one, has an attribute called initial, which clashes with APIView's initial() method. That's an odd choice of name - I usually put a verb in my method names for exactly the same reason. Changing it to initialize() or something similar sounds like a good idea, but I'm not sure how much of an impact would it make to existing code. Do you think people override it often?

Other than changing the initial dict to form_initial, my FormMixin is exactly the same as Django's. However, Django's ModelFormMixin does inherit from SingleObjectMixin, and the get_object() and get_queryset() methods are implemented somewhat differently in your GenericAPIView. I understand that this is due to GenericAPIView needing to serve as a middle ground between SingleObjectMixin and MultipleObjectMixin - but do you think that an implementation where it inherits from them instead would be possible? (Actually, I just had a long hard look at the get_queryset() methods of Django's SOM and MOM, and they're two slightly different wordings of the very same thing! Haven't compared other methods, though.)

I've also made a little mixin equivalent of ProcessFormView, and that successfully concludes my attempt to build ModelFormViews into a ViewSet. My Create and Update forms work just like they should; a Destroy form could easily bypass all of this and just send a DELETE request on submit.

Serializers and Renderers

I would imagine that my user-facing form workflow could do without the help of Serializers for the time being. However, if I decide to go the progressive enhancement route, with a basic form-driven UI augmented by AJAX API requests, which is why I went with DRF in the first place, validation could pose something of a DRY issue, with identical validation code having to go into both my Forms and my Serializers. The fact that one's validation methods are called clean_* and the other's validate_* doesn't help, either.

While exploring the code behind the Browsable API, I've noticed a method that looks a little out of place, called serializer_to_form_fields. Why is it there? I could imagine it used with TemplateHTMLRenderer or StaticHTMLRenderer just as well. Seems to me that this should be a Serializer method, or maybe a standalone function would be best. It also makes me think of the possibilites of converting between Serializers and Forms, or implementing Serializers on top of Forms, or perhaps delegating some or all of the validation to Forms and letting Serializers take care solely of format conversion? Any of those would be quite an endeavour, but it only serves to show that there's room to simplify things.

Bypassing serialization

Another thing about serializers. In my HTML-templated user-facing views, I prefer having the unserialized data, so that I can do dot lookups. (Although I briefly entertained the idea of populating my templates only using the serialized data.) To do this, I am using a subclass of TemplateHTMLRenderer as my main renderer, and it has an attribute bypass=True. My UserFacingMixin viewset mixin checks the accepted_renderer for that attribute. If it is true, then serialization is bypassed, and the unserialized data is passed into the Response object instead. (My renderer also passes the data into the template context as a dict, without unpacking it first, which makes writing templates much more manageable.)

What I did there really feels like going back and forth around different components of the DRF, but it's the best solution I could come up with. It poses several questions. Is it really the best idea to have serialization take place in handler methods in the first place? (But, if not, where else?) Perhaps there could be such a thing as a NullSerializer, which does nothing to the data? Doesn't sound like the most beautiful thing, but might be better than what I'm currently doing.

from django.template import RequestContext
from rest_framework.renderers import TemplateHTMLRenderer
class BypassRenderer(object):
bypass = True
class ElenTemplateHTMLRenderer(BypassRenderer, TemplateHTMLRenderer):
def resolve_context(self, data, request, response):
if response.exception:
data['status_code'] = response.status_code
return RequestContext(request, {'data': data})
from rest_framework.routers import SimpleRouter, Route
class ExtendedRouter(SimpleRouter):
routes = [
# List route.
Route(
url=r'^{prefix}/$',
mapping={
'get': 'list'
},
name='{basename}-list',
initkwargs={'suffix': 'List'}
),
# Create route
Route(
url=r'^{prefix}/create$',
mapping={
'get': 'add',
'post': 'add'
},
name='{basename}-create',
initkwargs={'suffix': 'Create'}
),
# Detail route.
Route(
url=r'^{prefix}/{lookup}$',
mapping={
'get': 'retrieve'
},
name='{basename}-detail',
initkwargs={'suffix': 'Instance'}
),
# Update route.
Route(
url=r'^{prefix}/{lookup}/update$',
mapping={
'get': 'edit',
'post': 'edit'
},
name='{basename}-update',
initkwargs={'suffix': 'Update'}
),
# Destroy route.
Route(
url=r'^{prefix}/{lookup}/delete$',
mapping={
'get': 'delete',
'post': 'destroy'
},
name='{basename}-destroy',
initkwargs={'suffix': 'Delete'}
),
# Dynamically generated routes.
# Generated using @action or @link decorators on viewset methods.
Route(
url=r'^{prefix}/{lookup}/{methodname}$',
mapping={
'{httpmethod}': '{methodname}'
},
name='{basename}-{methodnamehyphen}',
initkwargs={}
),
]
from django.core.exceptions import ImproperlyConfigured
from django.forms.models import modelform_factory
from django.http import Http404, HttpResponseRedirect, HttpResponseNotAllowed
from django.shortcuts import render
from django.template import RequestContext
from django.utils.encoding import force_text
from django.views.generic.base import ContextMixin
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
class TemplatesMixin(object):
def get_template_names(self):
meta = self.get_queryset().model._meta
app = meta.app_label
name = meta.object_name.lower()
templates = {
'list': ["%s/%s/list.html" % (app, name), "list.html"],
'retrieve': ["%s/%s/detail.html" % (app, name), "detail.html"],
'add': ["%s/%s/create.html" % (app, name), "create.html"],
'edit': ["%s/%s/update.html" % (app, name), "update.html"],
'delete': ["%s/%s/destroy.html" % (app, name), "destroy.html"],
}
if self.action in templates.keys():
selected_templates = templates[self.action]
else:
selected_templates = ['rest_framework/api.html']
return selected_templates
class FormMixin(ContextMixin):
form_initial = {}
form_class = None
success_url = None
def get_initial(self):
return self.form_initial.copy()
def get_form_class(self):
return self.form_class
def get_form(self, form_class=None):
if form_class is None:
return self.get_form_class()(**self.get_form_kwargs())
else:
return form_class(**self.get_form_kwargs())
def get_form_kwargs(self):
kwargs = {'initial': self.get_initial()}
if self.request.method in ('POST', 'PUT'):
kwargs.update({
'data': self.request.POST,
'files': self.request.FILES,
})
return kwargs
def get_success_url(self):
if self.success_url:
url = force_text(self.success_url)
else:
raise ImproperlyConfigured(
"No URL to redirect to. Provide a success_url.")
return url
def form_valid(self, form):
return HttpResponseRedirect(self.get_success_url())
def form_invalid(self, form):
return render(self.request, self.get_template_names(),
dictionary=self.get_context_data(form=form))
class ModelFormMixin(FormMixin):
form_object = None
def get_form_class(self):
if self.form_class:
return self.form_class
else:
if self.model is not None:
model = self.model
elif hasattr(self, 'object') and self.form_object is not None:
model = self.form_object.__class__
else:
model = self.get_queryset().model
return modelform_factory(model)
def get_success_url(self):
if self.success_url:
url = self.success_url % self.object.__dict__
else:
try:
url = self.object.get_absolute_url()
except AttributeError:
raise ImproperlyConfigured(
"No URL to redirect to. Either provide a url or define"
" a get_absolute_url method on the Model.")
return url
def get_form_kwargs(self):
k = super(ModelFormMixin, self).get_form_kwargs()
k.update({'instance': self.form_object})
return k
def form_valid(self, form):
self.object = form.save()
return super(ModelFormMixin, self).form_valid(form)
class ProcessFormMixin(object):
def _route_form(self, request, *args, **kwargs):
if request.method == 'GET':
return self.form_get(request, *args, **kwargs)
elif request.method == 'POST':
return self.form_post(request, *args, **kwargs)
else:
return HttpResponseNotAllowed()
def form_get(self, request, *args, **kwargs):
if not self.is_facing_user():
raise Http404
context = RequestContext(request,
self.get_context_data(form=self.get_form()))
return render(request, self.get_template_names(),
context_instance=context)
def form_post(self, request, *args, **kwargs):
form = self.get_form()
# TODO: pass through serializer?
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
class UserFacingMixin(TemplatesMixin, ModelFormMixin, ProcessFormMixin):
def is_facing_user(self):
return getattr(self.request.accepted_renderer, 'bypass', False)
def add(self, request, *args, **kwargs):
if self.is_facing_user():
self.form_object = None
return self._route_form(request, *args, **kwargs)
else:
return self.create(request, *args, **kwargs)
def edit(self, request, *args, **kwargs):
if self.is_facing_user():
self.form_object = self.get_object()
return self._route_form(request, *args, **kwargs)
else:
return self.update(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
if self.is_facing_user():
pass # todo implement delete confirmation form
else:
return self.destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
self.object_list = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(self.object_list)
if not self.is_facing_user():
if page is not None:
serializer = self.get_pagination_serializer(page)
else:
serializer = self.get_serializer(self.object_list, many=True)
return Response(serializer.data)
else:
data = page or self.object_list
return Response(data)
def retrieve(self, request, *args, **kwargs):
self.object = self.get_object()
if not self.is_facing_user():
serializer = self.get_serializer(self.object)
return Response(serializer.data)
else:
return Response(self.object)
class ExtendedModelViewSet(UserFacingMixin, ModelViewSet): pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment