Toast Driven

← Back to November 28, 2008

MultiResponse

As promised, here's the snippet of code I've been playing with. The behavior is that a client makes a request on your site but the request could be for any of several mime types. Perhaps they want to view the web page in their browser (HTML) or they're pulling in data (via XML or JSON). Serving up each one of these request via a different view is a lot of work and/or a lot of copy-paste for the same data, different templates.

So instead, you create a single view to handle all of the different request types and use a MultiResponse to serve them up. You register all the different types of content that URL/view will serve and let MultiResponse return the correct response.

multiresponse.py

from django.conf import settings
from django.shortcuts import render_to_response


ACCEPT_HEADER_MAPPING = {
    'text/html': 'html',
    'application/xml': 'xml',
    'text/xml': 'xml', # Broken but we're including it.
    'application/json': 'json',
    'text/plain': 'text',
}


class MultiResponse(object):
    """
    MultiReponse allows you to register different mime types and their templates
    and respond intelligently type to what the client requests.

    Templates must be registered with this class for use in combination with
    the mime type they serve. The mime types may be any of:

        * ``html``
        * ``xml``
        * ``json``
        * ``txt``

    If no appropriate match is found in the "Accept" header, the default mime
    type will be served.
    """
    def __init__(self, request):
        self.request = request
        self.templates = {}
        self.default_type = 'html'
        self.accept_header_mapping = ACCEPT_HEADER_MAPPING
        
        if hasattr(settings, 'ACCEPT_HEADER_MAPPING'):
            self.accept_header_mapping.update(settings.ACCEPT_HEADER_MAPPING)
    
    def client_accepted_mime_types(self):
        accepted_mime_types = []
        
        for mime in self.request.META.get('HTTP_ACCEPT').split(','):
            mime_no_whitespace = mime.strip()
            mime_info = mime_no_whitespace.split(';')
            # We don't handle levels/qualities right now, though we should eventually.
            cleaned_mime = mime_info[0]
            accepted_mime_types.append(cleaned_mime)
        
        return accepted_mime_types
    
    def register(self, mime_type, template, default=False):
        """
        Registers a mime type and corresponding template.
        
        By default, the first type registered becomes the default. You can
        override this by passing the third argument ``default`` as ``True`` on
        a later call as desired.
        """
        if self.templates == {} or default is True:
            self.default_type = mime_type
        
        self.templates[mime_type] = template
    
    def determine_extension(self):
        """Attempt to intelligently discern from the URL what the desired 
        extension is."""
        desired_extension = None
        request_path_pieces = [piece for piece in self.request.path.split('/') if piece != '']
        
        try:
            # Just look at the last bit.
            extension = request_path_pieces.pop()
            
            if extension in self.templates.keys():
                desired_extension = extension
        except IndexError:
            # Fail silently.
            pass
        
        return desired_extension
    
    def render(self, context=None, **kwargs):
        """Renders the desired template with the context. Accepts the same
        kwargs as render_to_response."""
        desired_template = ''
        content_type = 'text/html'
        
        if self.templates == {}:
            raise RuntimeError('You must register at least one mime type and template with MultiResponse before rendering.')
        
        if 'HTTP_ACCEPT' in self.request.META:
            extension = self.determine_extension()
            
            if extension in self.templates.keys():
                for mime in self.client_accepted_mime_types():
                    if mime in self.accept_header_mapping and self.accept_header_mapping[mime] == extension:
                        content_type = mime
                        desired_template = self.templates[extension]
                        break
    
        if not desired_template:
            try:
                desired_template = self.templates.get(self.default_type)
            except KeyError:
                raise RuntimeError('The default mime type could not be found in the registered templates.')
    
        response = render_to_response(desired_template, context, **kwargs)
        response['Content-Type'] = "%s; charset=%s" % (content_type, settings.DEFAULT_CHARSET)
        return response

Drop that into a file and import it into your views. Usage would look like:

from django.conf import settings
from django.shortcuts import render_to_response
from test.multiresponse import MultiResponse

def index(request, extension):
    sample_people = [
        {'name': 'Daniel', 'age': 26},
        {'name': 'John', 'age': 26},
        {'name': 'Jane', 'age': 20},
        {'name': 'Bob', 'age': 35},
    ]
    
    mr = MultiResponse(request)
    mr.register('html', 'index.html')
    mr.register('xml', 'people.xml')
    mr.register('json', 'people.json')
    return mr.render({
        'people': sample_people,
    })

The last bit (and only immediate downside to me of this) is the modifications you have to make to your URLconf. My test one looks like:

from django.conf.urls.defaults import *

urlpatterns = patterns('',
    (r'^(\w{3,4})?/?$', 'test.views.index'),
)

The (\w{3,4})?/? bit is necessary to match the different extensions one could provide. I really don't care for this and there are two other alternatives:

from django.conf.urls.defaults import *

urlpatterns = patterns('',
    (r'^$', 'test.views.index'),
    (r'^html/$', 'test.views.index'),
    (r'^xml/$', 'test.views.index'),
    (r'^json/$', 'test.views.index'),
    
    # ... or ...
    
    # Careful, this matches quite a bit... Make sure it comes last among like URLs.
    (r'^', 'test.views.index'),
)

I'd love to find a way around this but nothing obviously jumps out at me right now. There's some fun possibilities in this, such as extension to handle mobile/iPhone sites, Ajax-ifying an interface based around the existing pages in an app, etc.

The code is MIT licensed and will soon find its way into version control but for now this will do. For reference, it's loosely based around similar functionality, called respond_to in Rails. I'd love any feedback anyone has.

Toast Driven