Dynamic dispatching using Routes with TurboGears
05 Apr 2007
So far, I've been looking at modifying existing pages in Gibe (my still as-yet-unreleased TurboGears blog application) - adding widgets to post pages, dynamically adding the comment field and handling it for different comment formats, and adding additional fields at blog entry create/edit-time and handling these fields to add tagging (or whatever). Adding new pages (or replacing the default ones) is pretty much necessary - for example, to add a page where there is a list of all pages with a particular tag.
I use Routes for dispatching incoming URLs to functions in Gibe. It's not the default dispatcher in TurboGears, but it's pretty easy to set up (there's a TurboGears/Routes integration recipe on the TurboGears wiki).
Why go through the bother?
It makes adding new pages easy - no matter how complicated the URL structure is and where the dynamic portions are. It also makes it easy to pass through the dynamic portions, and also to pass through defaults if the dynamic portions don't exist. The killer function is named routes, which allows me to look up where something is (ie, generate the URL for it), and not hard-code the link to where the page is. That means that I can totally change the URL structure of the site without changing any code.
Routes has a single mapper, which is used to set up the rules to match incoming URLs to what needs to handle them, usually done at startup (but there's no rule that you can't add them later). You add a bunch of rules - giving them at least a name (optionally, but I always do), and a path with optional dynamic variables, and some idea of a "controller" (generally, what object we're going to call a method on) and "action" (generally, what method we're going to call on it), and the defaults for the dynamic variables if they're not provided.
You can also do some pretty helpful things - like instead of passing in manually the year, month, and day of a blog entry, as well as its slug or title, you can just pass in the post, and it'll sort it all out (using a filter function). This again means that you have the ability to change the entire way your URL structure works without changing code the generates URLs to particular pages.
In general, your routes code will look like this:
m = routes.Mapper()
m.connect('frontpage', '', controller='self', action='frontpage')
m.connect('index.html', 'index.html', controller='self', action='frontpage')
m.connect('posts', 'archives/:year/:month/:day/:slug', controller='self', action='post', _filter=post_expand)
m.connect('posts_by_id', ':(postid).html', controller='self', action='post', requirements=dict(postid=r'\d+'))
m.connect('archives_day', 'archives/:year/:month/:day/', controller='self', action='archives_day', _filter=date_expand_day)
m.connect('archives_month', 'archives/:year/:month/', controller='self', action='archives_month', _filter=date_expand_month)
m.connect('archives_month_deprecated', ':(year)-:(month).html', controller='self', action='archives_month', _filter=date_expand_month, requirements=dict(year='\d{4,}', month='\d{1,2}'))
m.connect('archives_year', 'archives/:year/', controller='self', action='archives_year', _filter=date_expand_year)
m.connect('archives', 'archives', controller='self', action='archives')
...
The first argument is the name for the route, and the second is the path. Of interest is how easy it is to handle deprecated URLs for external links with just a single rule and no code changes, but maintain a canonical URL internally for all URLs generated using the named route.
Using my general strategy in Gibe plugins, I convert all the rules from calls to mapper.connect into adding a tuple representing a single rule to the list of rules. I then pass the list of rules into each plugin, and allow the plugin to change the rules - generally just to add new rules, but potentially to remove some, or override them.
This ends up looking like this:
mapper = routes.Mapper()
maps = []
maps.append(('frontpage', '', dict(controller='self', action='frontpage')))
maps.append(('index.html', 'index.html', dict(controller='self', action='frontpage')))
maps.append(('posts', 'archives/:year/:month/:day/:slug', dict(controller='self', action='post', _filter=post_expand)))
maps.append(('posts_by_id', ':(postid).html', dict(controller='self', action='post', requirements=dict(postid=r'\d+'))))
maps.append(('archives_day', 'archives/:year/:month/:day/', dict(controller='self', action='archives_day', _filter=date_expand_day)))
maps.append(('archives_month', 'archives/:year/:month/', dict(controller='self', action='archives_month', _filter=date_expand_month)))
maps.append(('archives_month_deprecated', ':(year)-:(month).html', dict(controller='self', action='archives_month', _filter=date_expand_month, requirements=dict(year='\d{4,}', month='\d{1,2}'))))
maps.append(('archives_year', 'archives/:year/', dict(controller='self', action='archives_year', _filter=date_expand_year)))
maps.append(('archives', 'archives', dict(controller='self', action='archives')))
...
plugin.map_routes(maps, gibe_controllers)
for m in maps:
mapper.connect(m[0], m[1], **m[2])
The plugin.map_routes function does the work of passing the list of rules to each plugin's map_routes method.
I want to re-add the tags page, after removing it from the core. It just reuses the archive template (since they're both just lists of posts, and have the same pagination requirements):
class TagController(object):
@expose(template="genshi:gibe.templates.archives", content_type='text/html; charset=utf-8')
@paginate('posts', limit = 10)
def tag(self, blog, tagname, **kw):
t = model.Tag.get_by(name=tagname)
if not t:
raise cherrypy.NotFound()
posts = t.recent_posts(limit = None)
return dict(blog=blog, posts=posts, tag=t)
Also, there's the per-tag RSS feeds, using the TurboGears FeedController:
from turbogears import feed
class TagFeedController(feed.FeedController):
def get_feed_data(self, blog, tagname, **kw):
host = cherrypy.config.get('gibe.hostname', cherrypy.request.headers.get('Host', ''))
t = model.Tag.get_by(name=tagname)
last_entries = t.recent_posts(limit=5)
blog_base = cherrypy.config.get("gibe.blog_base", routes.url_for("frontpage", host=host))
return dict(
title=blog.name,
#author=dict(name=blog.owner.name),
link=blog_base,
updated=last_entries[0].creation_time,
generator="gibe",
entries=[
dict(title=post.title, content=post.content, summary=post.excerpt, updated = post.modification_time, published = post.creation_time,
#author=dict(name=post.author.name),
link=routes.url_for('posts', post=post, host=host)) for post in last_entries],
)
Hooking these two up as new pages handled by Gibe is quick and easy:
class TagsPlugin(Plugin):
def map_routes(self, maps, controllers):
controllers['tagController'] = tagController
controllers['tagFeedController'] = tagFeedController
maps.append(('tags', 'tags/:(tagname)', dict(controller='tagController', action='tag', _filter=tag_expand, conditions=dict(function=tag_condition))))
maps.append(('deprecated_tags_html', 'tags/:(tagname).html', dict(controller='tagController', action='tag')))
maps.append(('tags_rss', 'tags/:(tagname).rss', dict(controller='tagFeedController', action='rss2_0')))
maps.append(('tags_rss_deprecated', 'category_:(tagname).rss', dict(controller='tagFeedController', action='rss2_0')))