zend-frameworkresthierarchicalzend-rest-route

Zend Rest Route - how to create hierarchical URLs?


I'm developing an API using Zend Framework 1.12.3. I'm using Zend_Rest_Route, but I would like to have hierarchical URLs:

I'm considering of using this approach, since I'd have to assign certain subjects to certain professors, and I belive that this schema solves it neatly.

However, I'm having a hard time achieving hierarchical URLs. I've already tried:

  1. Zend_Controller_Router_Route with Chains, in the config .ini file, but since both the controller and the action have to be specified, when accessing http://api.example.com/professors/:professorId/subjects it always pointed to the same action (i.e., whatever the call method was - POST, PUT, GET, DELETE - it always pointed to the action specified in the config .ini file). For example, had I specified the getAction in the config file, using chains it would always call the getAction, no matter what was the method I've used. Currently, when having a POST call, it actually calls the postAction() (similarly happens for PUT, GET, DELETE, PATCH, HEAD and OPTIONS). My Controller file looks like this:

    class V1_ProfessorsController extends REST_Controller
    {
            public function optionsAction()
            {
                    // code goes here
            }
    
            public function headAction()
            {
                    // code goes here
            }
    
            public function indexAction()
            {
                    // code goes here - list of resources
            }
    
            public function getAction()
            {
                    // code goes here
            }
    
            public function postAction()
            {
                    // code goes here
            }
    
            public function putAction()
            {
                    // code goes here
            }
    
            public function patchAction()
            {
                    // code goes here
            }
    
            public function deleteAction()
            {
                    // code goes here
            }
    
    }
    
  2. Subclassing the Zend_Rest_Route and overriding the match() function as pointed out here. The thing is, that while this does work when calling http://api.example.com/professors/:professorId/subjects, it still uses the same ProfessorsController that is used when calling http://api.example.com/professors. I'm not sure about this, but I believe that it would be best having its own controller (e.g. ProfessorsSubjectsController).

Also, I've got a question. How should the hierarchical routes work? Would it be better to have different controllers for different resources/subresources? E.g., having ProfessorsController for http://api.example.com/professors/:professorId and ProfessorsSubjectsController for http://api.example.com/professors/:professorId/subjects/:subjectId ?


Solution

  • I found a solution somewhere that I modified slightly. This is a custom route class that does what I think we both want it to do.

    <?php 
    
    require_once "modules.inc";
    
    class Rest_Controller_Route extends Zend_Controller_Router_Route
    {
    
    /**
     * @var Zend_Controller_Front
     */
    protected $_front;
    
    protected $_actionKey     = 'action';
    
    /**
     * Prepares the route for mapping by splitting (exploding) it
     * to a corresponding atomic parts. These parts are assigned
     * a position which is later used for matching and preparing values.
     *
     * @param Zend_Controller_Front $front Front Controller object
     * @param string $route Map used to match with later submitted URL path
     * @param array $defaults Defaults for map variables with keys as variable names
     * @param array $reqs Regular expression requirements for variables (keys as variable names)
     * @param Zend_Translate $translator Translator to use for this instance
     */
    public function __construct(Zend_Controller_Front $front, $route, $defaults = array(), $reqs = array(), Zend_Translate $translator = null, $locale = null)
    {
        $this->_front      = $front;
        $this->_dispatcher = $front->getDispatcher();
    
        parent::__construct($route, $defaults, $reqs, $translator, $locale);
    }
    
    
    
    /**
     * Matches a user submitted path with parts defined by a map. Assigns and
     * returns an array of variables on a successful match.
     *
     * @param string $path Path used to match against this routing map
     * @return array|false An array of assigned values or a false on a mismatch
     */
    public function match($path, $partial = false)
    {
    
        $return = parent::match($path, $partial);
    
        // add the RESTful action mapping
        if ($return) {
            $request = $this->_front->getRequest();
            $path   = $request->getPathInfo();
            $params = $request->getParams();
    
            $path   = trim($path, '/');
    
            if ($path != '') {
                $path = explode('/', $path);
            }
    
            $lastParam = array_pop($path);
    
            // Determine Action
            $requestMethod = strtolower($request->getMethod());
            if ($requestMethod == 'head') {
                if (is_numeric($lastParam)) {
                    $return[$this->_actionKey] = 'head';
                    $return["id"] = $lastParam;
                }
            } else if ($requestMethod != 'get') {
                if ($request->getParam('_method')) {
                    $return[$this->_actionKey] = strtolower($request->getParam('_method'));
                } elseif ( $request->getHeader('X-HTTP-Method-Override') ) {
                    $return[$this->_actionKey] = strtolower($request->getHeader('X-HTTP-Method-Override'));
                } else {
                    $return[$this->_actionKey] = $requestMethod;
                }
    
                // Map PUT, DELETE and POST to actual create/update/delete actions
                // based on parameter count (posting to resource or collection)
                switch( $return[$this->_actionKey] ){
                    case 'post':
                        $return[$this->_actionKey] = 'post';
                        break;
                    case 'put':
                        $return[$this->_actionKey] = 'put';
                        $return["id"] = $lastParam;
                        break;
                    case 'delete':
                        $return[$this->_actionKey] = 'delete';
                        $return["id"] = $lastParam;
                        break;
                }
            } else {
                // if the last argument in the path is a numeric value, consider this request a GET of an item
                if (is_numeric($lastParam)) {
                    $return[$this->_actionKey] = 'get';
                    $return["id"] = $lastParam;
                } else {
                    if (isset($data[0]) && is_numeric($data[0])) {
                        $return[$this->_actionKey] = 'get';
                        $return["id"] = $lastParam;
                    } else {
                        $return[$this->_actionKey] = 'index';
                    }
                }
            }
        }
    
        return $return;
    
    }
    
    }
    

    To use this, create all your routes like this in your bootstrap or index.php, two examples:

    $route = new Rest_Controller_Route($front, 'customers/*', array('controller' => 'customers'));
    $router->addRoute('customers', $route);
    
    $route = new Rest_Controller_Route($front, 'customers/:customer_id/documents/*', array('controller' => 'customers-documents'));
    $router->addRoute('customersdocuments', $route);
    

    This works as a charm for me. Thou, consider that this is not my final solution so there might be dragons that I haven't discovered so be aware. :)