{ Hi! I'm Mike }
I'm a core developer with The Horde Project and a founding parter of Horde LLC - the company behind the world's most flexible groupware platform. This is my personal blog full of random thoughts about development and life in general.
July 4, 2008

Diving into Horde_Routes

Horde_Routes is a new Horde library that is derived from the Python Routes project.  I've been meaning to give it a look for some time now, and a recent rewrite / cleanup of an Ansel powered gallery site gave me the perfect opportunity to dive in.

In previous articles, I've outlined the basics of using Ansel to power an external gallery site.  In this article, we'll look at using Horde_Routes to map 'pretty' URLs to the PHP code.

The site is simple.  It is basically nothing more than a thin wrapper around some of Ansel's views, with an 'About Us' and 'Home' page thrown in for good measure.  I decided to implement the URLs like so:

/          - The home, or default route
/galleries - The top level, paged gallery list.
/x         - A gallery view where x represents the gallery id.
/x/y       - An image view where y represents the image id.

In all cases, paging is done with a 'page' URL parameter tacked on.  For purely static pages, such as the About Us page, I have a path such as:

/content/about

With the paths hashed out, it's time to look at the code. The first thing you need to do to enable Routes is to set up a rewrite rule on your webserver to pass all requests for your site to your controller script.  On my site, I decided to name my controller script dispatcher.php since that pretty accurately represents it's responsibilities.  How to go about setting up the rewrite rules will differ depending on your web server.  I use lighttpd for my sites, and, as I found out, this has a particular 'gotcha' when dealing with a Routes enabled site.

Apache has a switch that allows it to ignore any rewrite rules when the requested file already exists.  This makes dealing with things like stylesheets, images and script files easy.  With lighttpd, it's not so easy. Consider the following rewrite rule:

"^(.*)$" => "/dispatcher.php?url=$1"

This basically takes all requests for your site (I'm assuming the Routes site is at the root of your site) and forwards it to displatcher.php and tacks on the requested path as a URL parameter.  See the problem?  Lighttpd does not ignore rewrite rules for existing files, so a request for a stylesheet, /themes/default.css will fail. The same for images, javascript files etc...  To overcome this in lighttpd, you need to add a rewrite rule such as:

 

"^/(css|files|img|js)/.*$" => "$0"

Which, as you might guess, basically causes lighttpd to not rewrite the URLs that match the pattern given.  With that in mind, and a rewrite rule to make sure that the default route of '/' is properly dealt with, my rewrite rules for this site look like this:

 $HTTP["host"] =~ "^(www.)?theabramsgallery\.com$" {            url.rewrite-once += (            "^/?$" => "/dispatcher.php?url=/",            "^/(css|files|img|js)/.*$" => "$0",            "^(.*)$" => "/dispatcher.php?url=$1") }

The next step is to set up Routes and tell it about our desired mappings.  This should be done in either some sort of config file, or a base include file for your site.  First the code, then the explanation:

* Set up the Routes */ $m = new Horde_Routes_Mapper(); /* 'Home' route */ $m->connect('home', '', array('controller' => 'index')); /* General content Pages */ $m->connect('content', '/content/:content', array('controller' => 'content', 'action' => 'view')); /* Gallery List */ $m->connect('list', 'galleries', array('controller' => 'galleries', 'action' => 'index')); /* Gallery View */ $m->connect('gallery', '/:id', array('controller' => 'galleries', 'action' => 'view')); /* Image View */ $m->connect('image', '/:id/:image', array('controller' => 'images', 'action' => 'view')); /* Advertise our controllers */ $m->createRegs(array('index', 'galleries', 'images', 'content'));

The first line creates a new instance of the Mapper object.  With it, we 'connect' new mappings with the connect() method.  Each connect() call as called above, takes 3 arguments (it can actually take a variable number of arguments - see the documentation for details).  The first is the name of the route. It is not used at all when mapping a URL to an action, but it makes it easier when generating a URL within your site (see below).  The second argument is the Route Path and can be composed of both static and dynamic parts.  Static parts of the path are not preceded by a ':' , dynamic parts are. For example, the list route contains only a static path - galleries. This means that only the URL /galleries will match this route. The gallery route contains only a dynamic part, /:id.  So a URL such as /10 will match this route.  The third parameter is what actually determines what controller will be responsible for this action.  As you can see, it does not have to mirror the paths...for example, you can see that I use the galleries controller for both the list and the gallery routes.

 

OK. So, now we know what controllers are responsible for what routes. Great. Now what?  Well, now it's time to write the code that will handle the requests and pass off to the correct controller.  For this, as stated above, I used a file named dispatcher.php.  In that file is:

require_once dirname(__FILE__) . '/lib/base.php'; /* Grab, and hopefully match, the URL */ $url = Util::getFormData('url'); /* Get rid of any query args */ if (($pos = strpos($url, '?')) !== false) { list($url, $query) = explode('?', $url, 2); parse_str($query, $args); } else { $args = array(); } $match = $m->match($url); . . // Do stuff . /* Hand off to the proper controller */ $action = $match['action']; include dirname(__FILE__) . '/' . $match['controller'] . '.php';

In the first section of the code, we get the requested path from the query parameter.  We then have to strip off any query parameters that were passed in with the path. Routes will only match URLs with no query arguments. Then, we call the match() method of our Mapper object and are passed back an array representing the matched route.  This is a fairly simple site, I use a separate PHP file for each controller. I've omitted code from my dispatcher that doesn't relate to Routes, mainly I also set up a Horde_View object that I use in all my controllers to handle the displaying of the view template.

The only thing left really, for a basic Routes driven site is generating the URLs for the site. That's done with the Horde_Routes_Utils#urlFor method like so:

$url = $m->utils->urlFor('image', array( 'id' => '5', 'image' => '10'));

 This line would generate a URL for an image view like /5/10 where 5 is the gallery id and 10 is the image id. In the above code, you see that the array keys match the dynamic parts of the route path you defined with the connect() method.

I plan on refactoring all the websites under my control to use Horde_Routes, and I'd encourage you to take a look at the documentation at http://dev.horde.org/routes  to learn more!

Many thanks to Chuck who helped me sort out some things while working with Routes.