[See UPDATES]
This post is in several acts, each one a refactoring. Taking the stage will be some familiar-looking application code; behind the scenes lurks some enhanced framework code that makes the refactorings possible.
Our story starts with an old-school (pre-REST) routing configuration. It’s in Python, for Pylons and other Routes-based web frameworks, but let me reassure the Ruby audience that Routes borrows very heavily from Rails and their routing configurations look quite similar.
We will finish with collection(), an experimental (but I hope worthy) alternative to resource(), the routing helper that rose to prominence when Rails first made its big push to REST.
The starting point: textbook routing.py
from routes import Mapper
def make_map():
...
# Releases - Collection
mapper.connect('releases', '/releases',
controller='release', action='index', conditions=dict(method='GET'))
mapper.connect('create_release', '/releases',
controller='release', action='create', conditions=dict(method='POST'))
mapper.connect('new_release', '/releases/new',
controller='release', action='new', conditions=dict(method='GET'))
# Releases - Members
mapper.connect('release', '/releases/{id}',
controller='release', action='show',
requirements=dict(id='d+'), conditions=dict(method='GET'))
mapper.connect('update_release', '/releases/{id}',
controller='release', action='update',
requirements=dict(id='d+'), conditions=dict(method='PUT'))
mapper.connect('delete_release', '/releases/{id}',
controller='release', action='delete',
requirements=dict(id='d+'), conditions=dict(method='DELETE'))
mapper.connect('edit_release', '/releases/{id}/edit',
controller='release', action='edit',
requirements=dict(id='d+'), conditions=dict(method='GET'))
mapper.connect('release_notes', '/releases/{id}/notes',
controller='release', action='release_notes',
requirements=dict(id='d+'), conditions=dict(method='GET'))
Full of duplication, verbose, even ugly. Just because it sits in a config directory, does it have to look that bad?
Refactoring 1: Introducing submapper()
New in Routes 1.11 (so really quite new), submapper()provides the means to pull out parameters previously shared across multiple connect() calls.
One particularly nice feature is that the SubMapper objects returned by submapper() support the Python managed object protocol so if you’re on Python 2.5 or above you can use them with the with syntax like this:
from release_tool.lib.mapper import Mapper
def make_map():
...
# Releases - Collection
with mapper.submapper(
controller='release',
path_prefix='/releases') as c:
c.connect('releases', '', action='index',
conditions=dict(method='GET'))
c.connect('create_release', '', action='create',
conditions=dict(method='POST'))
c.connect('new_release', '/new', action='new',
conditions=dict(method='GET'))
# Releases - Members
with mapper.submapper(
controller='release',
path_prefix='/releases/{id}',
requirements=dict(id='d+')) as m:
m.connect('release', '', action='show',
conditions=dict(method='GET'))
m.connect('update_release', '', action='update',
conditions=dict(method='PUT'))
m.connect('delete_release', '', action='delete',
conditions=dict(method='DELETE'))
m.connect('edit_release', '/edit', action='edit',
conditions=dict(method='GET'))
m.connect('release_notes', '/notes', action='release_notes',
conditions=dict(method='GET'))
That’s a definite improvement (and credit where credit is due – submappers are great innovation, though a small bug requires our local extensions to be imported here), but notice that there’s still some duplication between the two submappers blocks, the first one corresponding to the collection resource, the second to that same collection’s members. Wouldn’t it be cool if we could nest them?
Refactoring 2: Submapper nesting
On the surface, the Routes 1.11 API doesn’t appear to support submapper nesting, but the SubMapper objects do indeed nest. Adding a submapper() method to SubMapper is a trivial change, and it take only minor internal tweaks for deeper nestings to function correctly.
# Releases
with mapper.submapper(
controller='release',
path_prefix='/releases') as c:
# Collection
c.connect('releases', '', action='index',
conditions=dict(method='GET'))
c.connect('create_release', '', action='create',
conditions=dict(method='POST'))
c.connect('new_release', '/new', action='new',
conditions=dict(method='GET'))
# Members
with c.submapper(
path_prefix='/{id}',
requirements=dict(id='d+')) as m:
m.connect('release', '', action='show',
conditions=dict(method='GET'))
m.connect('update_release', '', action='update',
conditions=dict(method='PUT'))
m.connect('delete_release', '', action='delete',
conditions=dict(method='DELETE'))
m.connect('edit_release', '/edit', action='edit',
conditions=dict(method='GET'))
m.connect('release_notes', '/notes', action='release_notes',
conditions=dict(method='GET'))
We seem to be on to something here, but what about the repetition (in one guise or another) of the resource name “release”? And what’s with those strange empty string ('') parameters?
Refactoring 3: Links and actions
To fix the repetition issue it’s clear that we need helpers that will generate those connect() calls for us more intelligently. But before we do that, let’s recognise that there are really two kinds of routes being generated here:
- Links to singleton subresources that support only one HTTP method, the most ubiquitous examples being the
new and edit representations for which GET is the applicable HTTP method, but special subresources that are the targets for POST actions are common too;
- Actions that correspond directly to HTTP methods:
index and create for GET and POST[1] on collection resources, show, update, delete for GET, PUT, DELETE on member resources.
It’s that second type that give rise to those strange empty strings as there is no further navigation to be done relative to the target resource.
So let’s add link() and action() helpers:
# Releases
with mapper.submapper(
controller='release',
path_prefix='/releases') as c:
# Collection
c.action(action='index', name='releases')
c.action(action='create', method='POST')
c.link('new')
# Members
with c.submapper(
path_prefix='/{id}',
requirements=dict(id='d+')) as m:
m.action(action='show', name='release')
m.action(action='update', method='PUT')
m.action(action='delete', method='DELETE')
m.link(rel='edit')
m.link(rel='notes', name='release_notes')
Things are still moving in the right direction, but given that most of those links and actions follow a very well-worn convention, how about specific helpers for those?
Refactoring 4: More helpers
I’ll be honest – this is just an intermediate stage. You can see where we’re headed now though:
# Releases
with mapper.submapper(
collection_name='releases',
controller='release',
path_prefix='/releases') as c:
# Collection
c.index()
c.create()
c.new()
# Members
with c.submapper(
path_prefix='/{id}',
requirements=dict(id='d+')) as m:
m.show()
m.update()
m.delete()
m.edit()
m.link(rel='notes', name='release_notes')
Refactoring 5: Parameter-driven generation
We’ll be using those same names a lot across our resources, so let’s just identify the ones we want in a list:
# Releases
with mapper.submapper(
collection_name='releases',
controller='release',
path_prefix='/releases',
actions=['index', 'create', 'new']) as c:
# Members
with c.submapper(
path_prefix='/{id}',
requirements=dict(id='d+'),
actions=['show', 'update', 'delete', 'edit']) as m:
m.link(rel='notes', name='release_notes')
But even this pattern will be repeated across resources, so let’s define a collection() helper that does it all:
Refactoring 6: Using the collection() helper
with mapper.collection(
'releases',
'release',
member_options={
'requirements': {'id': 'd+'}}) as c:
c.member.link(rel='notes', name='release_notes')
And we’re done.
Let’s compare this to the equivalent resource() call:
# Releases
mapper.resource(
'release',
'releases',
controller='release',
# no requirements!
member = {'notes':'GET'})
Superficially similar, but I’m unable to specify requirements on member paths and I don’t get to name my custom route either – the generated name “notes_release” just isn’t right! The bottom line is that resource() is approaching a dead end; its only way forward is to make its parameters ever more complex.
So… how about we put resource() on notice and bring in something more flexible and – dare I say – Pythonic? And wouldn’t it look just as nice in Ruby too? Answers on a postcard please…