Thursday, October 2, 2008

Zend Framework: Using Transparent Routes

I love Zend Framework. I have to admit, my only othe real indepth experience with a rapid development framework in PHP has been CakePHP, though. I hope that doesn't discredit me too much.

Routing with Zend Framework takes on the best of both worlds: It's very simple in it's default form, but tools are provided that allow you to take on as much complexity as you desire. Even if the shipped classes aren't enough for you, you can easily write your own route types, or even your own router.

Update

I've decided that this method of solving my problem was a little complicated and not very scalable. Please check out this article for a better solution.

The Problem

For my current project, I wanted my routes to work like the default routes, with a slight twist. The first part of the URL needed to be a content type to return to the user, and the following string be the controller, action, and parameters. So, for example,

/xml/auth/login
     controller: auth
     action: login
     requestedContentType: xml

/html/auth/login
     controller: auth
     action: login
     requestedContentType: html

This is rather simple using a standard route in zend framework:

$oRouter = $oController->getRouter();
$oRoute = new Zend_Controller_Router_Route(
  ':/requestedContentType/:controller/:action/*
);
$oRouter->addRoute( 'awesomeRoute', $oRoute );
$oController->setRouter( $oRouter );

Perfect. The accomplishes everything we need, and is very simple. But then I had to go and throw a wrench in the works. In addition to having a URI supplied requestedContentType, I wanted to:

  • Make sure that only supported content types were parsed in this manner.
  • Be able to not specify a content type, and have it default to html.
  • Not break Zend Framework's default parameterization behavior.

Example parsed routes:

/xml/content/books
     controller: content
     action: books
     requestedContentType: xml

/html/content/books
     controller: content
     action: books
     requestedContentType: html

/content/books
     controller: content
     action: books
     requestedContentType: html

/rss/content/books/page/1
     controller: content
     action: books
     requestedContentType: rss
     page: 1

/content/books/foo/bar
     controller: content
     action: books
     requestedContentType: html
     foo: bar

By now, you may have guessed it -- A custom is in order. Unfortunately, I messed around with chaining routes of different types for hours before coming to this conclusion. This was mosty out of fear, though.

The Solution

The approach I took was slightly different than the built-in routing flow. As shipped, Zend's routers will loop through the routes you pass to it to it until it finds the first one to match (in the order of which they were supplied). Once the route matches, the parameters extracted through that route are assigned to the request, and control is returned to the dispatcher.

I decided to rework this a little bit to meet my needs. I essentially wanted to pass in a set of filters which could extract parameters from a URI before the actual routing takes place. This would allow me to extract the content type from the URI if it existed, but allow other routes to actually handle the routing itself. I called these routes 'Transparent Routes', since they are applied the same way as Zend's routes, however they do not actually invoke routing.

Here is my flow:

  1. Setup Your Routes
  2. Assign Transparent Routes
  3. Assign Routes
  4. When route() is invoked, the transparent routes are applied, which extracts parameters from the URI
  5. The standard routing mechanism is triggered. When a route is matched, only parameters which are also extracted from this route will be applied.
This completely solved my immediate issue, and allowed opportunties for much more down the road. Almost any parameter can be generated this way.

The TransparencyRouter Class

/**
 * Extended router which allows applying transparent routes which
 * only serve to extract paramters.  Once transparent routes
 * have been applied, controll is returned to the standard
 * Zend_Controller_Router_Rewrite.  
 * 
 * @author A.J. Brown
 * @version 1.0
 *
 */
class TransparencyRouter extends Zend_Controller_Router_Rewrite
{
 protected $_transparentRoutes = array();

 /**
  * Adds a route which will be used for extracting parameters only.
  *
  * @param string $sName the name for this route
  * @param Zend_Controller_Router_Route_Abstract $oRoute
  * @return TransparencyRouter
  */
 public function addTransparentRoute(
  $sName,
  Zend_Controller_Router_Route_Abstract $oRoute
 )
 {
        if (method_exists($oRoute, 'setRequest')) {
            $oRoute->setRequest($this->getFrontController()->getRequest());
        }

        $this->_transparentRoutes[$sName] = $oRoute;

        return $this;
 }

 /**
  * @see Zend_Controller_Router_Rewrite
  *
  * @param Zend_Controller_Request_Abstract $oRequest
  * @return Zend_Controller_Request_Abstract
  */
 public function route( Zend_Controller_Request_Abstract $oRequest )
 {

  foreach (array_reverse($this->_transparentRoutes) as $name => $route) {

      if (!method_exists($route, 'getVersion') || $route->getVersion() == 1) {
                $match = $oRequest->getPathInfo();
            } else {
                $match = $request;
            }

      if ($params = $route->match( $match ) ) {
                $this->_setRequestParams($oRequest, $params);
                $iMatched++;
            }
  }

  return parent::route( $oRequest );
 }
}

Example Usage

//--------------------------
// Configure routes
//--------------------------

$oRouter = new TransparencyRouter();


$oInterpreterRoute = new Zend_Controller_Router_Route(
 ':controller/:action/*' );


$oNonContentTypeRoute = new Zend_Controller_Router_Route_Regex(
 '(\w+)/(\w+)(\/.*)?',
 array(
  'requestedContentType' => 'html'),
 array(
  1 => 'controller',
  2 => 'action' )
);

$oContentTypeRoute = new Zend_Controller_Router_Route_Regex(
 // TODO the content types themselves should be
        // pulled in from a config file.
        '(html|xml)\/(\w+)\/(\w+)(\/.*)?',
 array(),
 array(
  1 => 'requestedContentType',
  2 => 'controller',
  3 => 'action' )
);

$oRouter->addTransparentRoute( 'nonContentType', $oNonContentTypeRoute  );
$oRouter->addFilteringRoute( 'contentType', $oContentTypeRoute );
$oRouter->addRoute( 'generic', $oInterpreterRoute );
$oRouter->addRoute( 'contentType', $oContentTypeRoute );

//-------------------------
// Setup controller
//-------------------------
$oController = Zend_Controller_Front::getInstance();
$oController->setRouter( $oRouter );

1 comment:

A.J. Brown said...

After sleeping on it a bit, I've decided to rework this. It feels a little complex giving my very basic need: to supply an optional content type as part of the URL.

Here is what I'm thinking:

* Add a "addAvalailableContentType()" (a better name is deserved) function to the router.
* On route(), detect the previously supplied content types, then strip the content type part from the URI.
*pass the new URI to the request object
* route based on the "cleaned" URI

This will make route configuration cleaner, and will make the content typing transparent.

Without actually looking at the classes, I think I'll need a new Request object with the following:

* a field to store the original unmodified URI
* a field to store the requested content type at the request level. If we're modifying the URI at this level, we better make the data we're "stealing" available as well.

I'll post a follow up when this is completed.