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!