Media-Type Based Responses With Django REST Framework

Nowadays a typical REST API responds with data in JSON format. But what if you wanted to serve data in a different format (and not just a serialization format) using the same endpoint, but specified by the client?

Django REST Framework renderers can help us achieve this nice feature.

Use Case: ONIX For Books Compatible API

ONIX for books is an XML format for sharing bibliographic data pertaining to both traditional books and eBooks.

Let's say our application serves metadata about books (title, author, etc.) in the usual JSON format, but we also want to provide data in ONIX format, which is an XML standard that defines how the metadata should be described and put together for consumption.

Here is an example:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE ONIXMessage SYSTEM "http://www.editeur.org/onix/2.1/02/reference/onix-international.dtd">
<ONIXMessage>
  <Header>
    <FromCompany>Broadview Press</FromCompany>
    <FromEmail>support@booksonix.com</FromEmail>
    <SentDate>201311091423</SentDate>
    <MessageNote>Generated by BooksoniX</MessageNote>
    <DefaultLanguageOfText>eng</DefaultLanguageOfText>
  </Header>
  <Product>
    <RecordReference>9781551116853</RecordReference>
    <NotificationType>03</NotificationType>
    <ProductIdentifier>
      <ProductIDType>02</ProductIDType>
      <IDValue>1551116855</IDValue>
    </ProductIdentifier>
    <ProductIdentifier>
      <ProductIDType>03</ProductIDType>
      <IDValue>9781551116853</IDValue>
    </ProductIdentifier>
    <ProductIdentifier>
      <ProductIDType>15</ProductIDType>
      <IDValue>9781551116853</IDValue>
    </ProductIdentifier>
    <Barcode>10</Barcode>
    <ProductForm>BC</ProductForm>
    <ProductFormDetail>B102</ProductFormDetail>
    <Title>
      <TitleType>01</TitleType>
      <TitleText>The Aesthetics Of Human Environments</TitleText>
    </Title>

    <!-- Omitted -->
  </Product>
</ONIXMessage>

As you can see, the format follows a certain structure. For example, it uses a custom DTD, everything is contained within a <ONIXMessage> element, there is a <Header> element before the book's metadata section, etc.

This is the kind of response we want our API to be able to serve, using the same endpoint that would return a JSON response like the following:

{
  "title": "The Aesthetics Of Human Environments",
  "id": "9781551116853"
}

The API View

Assuming we have a Book model and BookSerializer already defined in our application, we can initally define our view like this:

from rest_framework import viewsets


class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

This will create an endpoint where we can perform GET requests (as well as other types of requests) to obtain the book's data in JSON format as I described earlier.

Adding a Custom Renderer For ONIX

Let's go ahead and create a custom renderer that will display the data in the ONIX format the client wants. The client will specify this by using the Accept HTTP header.

We can create a new directory for ONIX related stuff and add our renderers there:

# onix/renderers.py
from rest_framework.renderers import BaseRenderer

# This assumes our app is called 'api'. More on these imports later
from api.onix.builders import OnixV2XMLBuilder, OnixV3XMLBuilder


class OnixV2Renderer(BaseRenderer):
    media_type = "application/onix2+xml"

    def render(
        self, data, accepted_media_type=None, renderer_context=None
    ):
        if data is None:
            return ""

        builder = OnixV2XMLBuilder(data=data)
        return builder.build_xml()


class OnixV3Renderer(BaseRenderer):
    media_type = "application/onix3+xml"

    def render(
        self, data, accepted_media_type=None, renderer_context=None
    ):
        if data is None:
            return ""

        builder = OnixV3XMLBuilder(data=data)
        return builder.build_xml()

There is some repetition here that can be cleaned up, but basically what we have here is a renderer for each ONIX version we want to serve, in this case version 2 and version 3. The way the XML data is built varies between these versions.

Pay attention to the media_type attribute of each renderer. This specifies which renderer will be used when the Accept header matches any of these media types. So if we want the data in ONIX v2, we can use Accept: application/onix2+xml, and if we want the data in ONIX v3 we can use Accept: application/onix3+xml.

I came up with these media types myself so you can actually name them however you like. However, the +xml suffix at the end is important since this makes HTTP clients such as Postman to automatically format and highlight the response. In this case, an XML response.

Creating The Builders

If you noticed, the renderers import some builders and leave all the responsibility of constructing the XML response to them. Let's describe a simple example of the interface:

# onix/builders.py
import abc

from lxml import etree


class BaseOnixXMLBuilder(abc.ABC):
    def __init__(self, data, encoding="UTF-8"):
        self._data = data
        self._encoding = encoding

    @abc.abstractmethod
    def build_xml(self):
        pass

    @abc.abstractmethod
    def _build_header_xml(self):
        pass

    @abc.abstractmethod
    def _build_product_xml(self):
        pass

All ONIX version-specific builders will follow this interface. So we can implement a v2 builder like this:

class OnixV2XMLBuilder(BaseOnixXMLBuilder):
    DTD = '<!DOCTYPE ONIXMessage SYSTEM "http://www.editeur.org/onix/2.1/02/reference/onix-international.dtd">'

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def build_xml(self):
        onix_message = etree.Element("ONIXMessage")

        onix_message.append(self._build_header_xml())
        onix_message.append(self._build_product_xml())

        return etree.tostring(
            onix_message,
            encoding=self._encoding,
            pretty_print=True,
            doctype=self.DTD,
            xml_declaration=True
        )

    def _build_header_xml(self):
        header = etree.Element("Header")

        # How and where you pull or provide this data is up to you
        from_company = etree.Element("FromCompany")
        from_company.text = "MyCompany"
        header.append(from_company)

        from_email = etree.Element("FromEmail")
        from_email.text = "Myemail"
        header.append(from_email)

        return header

    def _build_product_xml(self):
        product = etree.Element("Product")

        record_reference = etree.Element("RecordReference")
        product.append(record_reference)

        # Create the structure based on the book's data

        return product

Each method in the interface is responsible for building a certain section of the XML structure. The renderers will call and use these builders and pass in the Book object or its data, which the builder can then use to build the actual XML that will be served in the response.

Integrating It Into The View

Now we only need to include our new renderers into our view:

from rest_framework.renderers import JSONRenderer

from api.onix.renderers import OnixV3Renderer, OnixV2Renderer


class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    renderer_classes = (JSONRenderer, OnixV3Renderer, OnixV2Renderer)

We still include JSONRenderer since we still want our endpoint to serve data in JSON format.

And with this you are all set! 🎉 Now try sending a request using Accept: application/onix2+xml header and your response will be the XML that was put together by our builder.

If you ommit the Accept header, the usual JSON response will be served!

References

  1. ONIX: For People Who Don't Really Work With Metadata
django djangorestframework

Comments

comments powered by Disqus