26. 1. 2016 v IT

Implementing HATEOAS with Django REST framework

Django REST framework is one of two major solutions for building RESTful APIs (the other one is Tastypie).

For the years I work with django I've used both frameworks and nowadays I prefer DRF over Tastypie because of its web browsable API. This feature can save you so much time when developing the API because it makes testing much easier and can be used instead of documentation as well. OK. That was just for introduction.

The problem

Let's start with the story, which could be called "How I asked a stupid question on stackoverflow and then found the answer myself to find out that what I wanted to solve doesn't really make sense."

Well, it started as a university course assignment: to implement a sample LEVEL 3 mature API by Richardson maturity model. Level 3 basically means that we had to implement HATEOAS and also stick with other REST constraints. When you look at WIKI example of HATEOAS response everything looks quite clear:

<?xml version="1.0"?>
<account>
   <account_number>12345</account_number>
   <balance currency="usd">100.00</balance>
   <link rel="deposit" href="http://somebank.org/account/12345/deposit" />
   <link rel="withdraw" href="http://somebank.org/account/12345/withdraw" /> 
   <link rel="transfer" href="http://somebank.org/account/12345/transfer" />
   <link rel="close" href="http://somebank.org/account/12345/close" />
 </account>

The idea

But if you start to think about complex API with multiple resources it gets more complicated. First thing I had to realize is that set of links provided in response depends theoretically on three inputs:

  • Requested Resource
  • Used operation (by operation I mean retrieve, list, update or delete)
  • And state of the "instance of resource"

Then you have to find out how to implement this in DRF, which can be quite difficult to find out if you are not very well familiar with DRF. Don't worry, here's the code (I don't say it's the real clean implementation but I think its good tradeoff between efficiency and clean code). So here are our generic classes:

from collections import OrderedDict

from rest_framework.response import Response
from rest_framework import status
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin, CreateModelMixin, DestroyModelMixin
from rest_framework.viewsets import GenericViewSet
from rest_framework.pagination import PageNumberPagination


class ExtraLinksAwarePageNumberPagination(PageNumberPagination):

    def get_paginated_response(self, data, links=[]):
        return Response(OrderedDict([
            ('count', self.page.paginator.count),
            ('next', self.get_next_link()),
            ('previous', self.get_previous_link()),
            ('results', data),
            ('_links', links),
        ]))

def create_link(desc, href, method=None):
    result = {
        'desc': desc,
        'href': href,
    }
    if method:
        result['method'] = method
    return result


class HateoasListView(ListModelMixin, GenericViewSet):
    pagination_class = ExtraLinksAwarePageNumberPagination

    def get_list_links(self, request):
        return {}

    def get_paginated_response(self, data, links=None):
        assert self.paginator is not None
        return self.paginator.get_paginated_response(data, links)

    def linkify_list_data(self, request, data):
        return data

    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            data = self.linkify_list_data(request, serializer.data)
            return self.get_paginated_response(data, links=self.get_list_links(request))

        serializer = self.get_serializer(queryset, many=True)
        data = self.linkify_list_data(request, serializer.data)


        return Response(OrderedDict([
            ('results', data),
            ('_links', self.get_list_links(request))
        ]))


class HateoasRetrieveView(RetrieveModelMixin, GenericViewSet):
    def get_retrieve_links(self, request, instance):
        return {}

    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        data = serializer.data
        data['_links'] = self.get_retrieve_links(request, instance)
        return Response(data)


class HateoasUpdateView(UpdateModelMixin, GenericViewSet):
    def get_update_links(self, request, instance):
        return {}

    def update(self, request, *args, **kwargs):
        partial = kwargs.pop('partial', False)
        instance = self.get_object()
        serializer = self.get_serializer(instance, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        self.perform_update(serializer)
        data = serializer.data
        data['_links'] = self.get_update_links(request, instance)
        return Response(data)


class HateoasCreateView(CreateModelMixin, GenericViewSet):
    def get_create_links(self, request, data):
        return {}

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.get_success_headers(serializer.data)
        data = serializer.data
        data['_links'] = self.get_create_links(request, serializer.data)
        return Response(data, status=status.HTTP_201_CREATED, headers=headers)


class HateoasDestroyView(DestroyModelMixin, GenericViewSet):
    def get_destroy_links(self, request, instance):
        return {}

    def destroy(self, request, *args, **kwargs):
        instance = self.get_object()
        data = {'_links': self.get_destroy_links(request, instance)}
        self.perform_destroy(instance)
        return Response(data, status=status.HTTP_204_NO_CONTENT)

And this is an example of how it can be used:

class BandViewSet(HateoasListView, HateoasRetrieveView, HateoasUpdateView, HateoasCreateView,
                  HateoasDestroyView):
    queryset = Band.objects.all()
    serializer_class = BandSerializer

    def get_list_links(self, request):
        return [
            {
                'desc': 'Self',
                'href': request.build_absolute_uri(request.path),
                'method': 'GET',
            },
            {
                'desc': 'New Band',
                'href': request.build_absolute_uri(request.path),
                'method': 'POST'
            }
        ]

    def linkify_list_data(self, request, data):
        for band in data:
            detail_link = request.build_absolute_uri(reverse('band-detail', kwargs={'pk': band['id']}))
            band['_links'] = [
                create_link('Band detail', detail_link, 'GET'),
            ]
        return data

    def get_retrieve_links(self, request, instance):
        self_link = request.build_absolute_uri(request.path)
        attendance_link = request.build_absolute_uri(reverse('band-attendances-list', kwargs={'band_pk': instance.pk}))
        memberlist_link = request.build_absolute_uri(reverse('band-members-list', kwargs={'band_pk': instance.pk}))

        return [
            create_link('Self', self_link, 'GET'),
            create_link('Update self', self_link, 'PUT'),
            create_link('Delete self', self_link, 'DELETE'),
            create_link('List of attendances', attendance_link, 'GET'),
            create_link('Add new attendances', attendance_link, 'POST'),
            create_link('List of members', memberlist_link, 'GET'),
            create_link('Add new member', memberlist_link, 'POST')
        ]

    def get_create_links(self, request, data):
        detail_link = request.build_absolute_uri(reverse('band-detail', kwargs={'pk': data['id']}))

        return [
            create_link('Detail of band', detail_link, 'GET')
        ]

    def get_update_links(self, request, instance):
        detail_link = request.build_absolute_uri(reverse('band-detail', kwargs={'pk': instance.pk}))

        return [
            create_link('Detail of band', detail_link, 'GET')
       ]

The enlightenment

OK. So using this code we've finished the assignment. We have L3 REST API. It's perfect except it's totally useless on real projects. Let's return back and modify the first example from wikipedia to comply with uniform interface constraint:

<?xml version="1.0"?>
<account>
   <account_number>12345</account_number>
   <balance currency="usd">100.00</balance>
   <link rel="deposit" href="http://somebank.org/account/12345" />
   <link rel="withdraw" href="http://somebank.org/account/12345" /> 
   <link rel="transfer" href="http://somebank.org/account/12345" />
   <link rel="close" href="http://somebank.org/account/12345" />
 </account>

You can do the similar excercise with L3 example at martinfowler.com. When you remove rel attributes from links which are both redundant and confusing (because what can you do with resource should depend on HTTP method you will use), all that remains are just links to related resources.

Can you see what's wrong with these examples and with my HATEOAS implementation? Most of the links provide us NO NEW INFORMATION. We know all this from the fact that it is RESTful API.

The solution

So, after reading all the above, it is the moment to realize that what DRF provides us by default is enough and we don't need anything more. Hyperlinked relations and IDs together with Pagination are the only HATEOAS features that really make sense and help us to make API discoverable and browsable.

Searching the internet I couldn't find any example of real complex L3 REST API. I think it's just because it doesn't make any sense. 

All source codes in this article are published under MIT license


Čtěte dále