Rolling out an API for your cakePHP app Part ++1: The Solution

In the previous post I covered creating a basic API using cakePHP and what limitations are imposed if you want to extend the API over the course of several versions. This post is going to cover the architecture of a possible solution, I don't claim its the best solution out there and feedback is welcomed.

A quick rundown of the goals for this solution:

  • Contain a single version of the API to one file/class to improve maintenance
  • Orderly folder structure for API view files
  • Simple and minimal routing rules
  • A more DRY solution minimising code (and file) replication
  • Easy deployment of new API versions
  • A system to return API specific errors
  • A place for non object specific API methods

Overview of Solution Architecture

I created an API component to dispatch API requests to an API class in the vendors folder. Based on the version requested the component dispatches the request to the corresponding class, if it exists. That class then dispatches the request to the corresponding method, based on naming convention. The class also takes care of locating the correct view to use for each method/API version/response type combination, making use of the RequestHandler. The different API version classes can be extended so that a new version can inherit the functionality of the previous without the need to replicate methods.
Without further ado...

Routing

The routing rules are going to be very simple, one rule to rule them all and in the darkness bind them:

Router::connect('/:version/:controller/:action/*', array('prefix' => 'api', 'api' => true), array('version'=>'[0-9]+\.[0-9]+'));

This rule should route any URLs like:

http://yoursite.com/0.2/posts/get.xml
http://yoursite.com/10.3/posts/get.xml

To the posts controller api_get() method. The posts controller should include the API component in order to execute the following:

var $components = array('Auth', 'Acl', 'RequestHandler', 'Api');
...
function api_get() {
    $this->Api->dispatch();
}

The API Component

App::import('Vendor', 'Api_0_2', array('file' =>'api'.DS.'0.2'.DS.'api.php'));
class ApiComponent extends Object
{
    public $api = null;

    /**
     * Initialize API component.
     * Called before Controller::beforeFilter()
     *
     * @param $controller
     * @param $settings
     * @return void
     */
    function initialize(&$controller, $settings = array()) {
        $this->controller =& $controller;
    }

    /**
     * Dispatch the API request to the correct API class depending on version
     *
     * @return void
     */
    function dispatch() {

        //Want to dispatch to correct method in API class, get the name of the class
        $className = 'Api_'.str_replace('.', '_', $this->controller->params['version']);

        //Confirm class exists
        if (class_exists($className)) {
            $this->api = new $className();
        }
        else {
                //No need to worry about apiErrors just at the moment
            $this->cakeError('apiError', array('apiErrorCode'=>1001));
        }

        //Pass in controller
        $this->api->dispatch($this->controller);
    }
}

In this component I include the various API version classes that need to be instantiated for each version of the API that is requested. Once the correct class has been created dispatch() in that class is invoked.

The API Classes

The API classes all extend one central API class which lives at vendors/api.php.

App::import('Inflector');
class Api extends Object
{
    public $controller;
    public $version = null;
    public $rendered = false;
 
    /**
     * Error codes for API errors
     */
    private $errorCodes = array(
        1001 => array('httpCode'=>500, 'errorMessage'=>'Version does not exist.'),
        1002 => array('httpCode'=>500, 'errorMessage'=>'Unknown output file.'),
        1003 => array('httpCode'=>500, 'errorMessage'=>'Unrecognised method.'),
    );
 
    /**
     * Retrieve data relating to an API error code
     * 
     * @param $errorCode Int API error code
     * @return Array Data associated with the error code.
     */
    public function getError($errorCode) {
        if (in_array($errorCode, array_keys($this->errorCodes))) {
            return $this->errorCodes[$errorCode];
        }
        return false;
    }
 
    /**
     * Render the API request using a version to select the view.
     * 
     * @return void
     */
    function render() {
 
        $controller = strtolower(Inflector::underscore($this->controller->name));
        $version = $this->version;
        $folder = $this->controller->RequestHandler->prefers();
        $view = str_replace('api_', '', $this->controller->action);
 
        //Check file exists before rendering
        $viewFile = new File(VIEWS . "/$controller/api/$version/$folder/$view.ctp");
 
        if ($viewFile->exists()) {
            $this->controller->render("/$controller/api/$version/$folder/$view");
            $this->rendered = true;
        }
        else {
 
            /**
             * Try to get previous missing view from previous API version
             * Perhaps this is not the best approach? But allows us to reuse earlier API version 
             * views without duplicating files for new API releases.
             */
            $version = substr(strstr($version, '.'), 1);
 
            //NOTE: the version of our API actually started at 0.3, so you might need to edit some of the for loops in the code for your particular case
            for ($i = $version; $i >= 3; $i--) {
                $viewFile = new File(VIEWS . "/$controller/api/0.$i/$folder/$view.ctp");
                if ($viewFile->exists()) {
                    $this->controller->render("/$controller/api/0.$i/$folder/$view");
                    $this->rendered = true;
                    break;
                }
            }
        }
        if (!$this->rendered) {
            $this->cakeError('apiError', array('apiErrorCode'=>1002));
        }
    }
 
    /**
     * Dispatch the API request to the corresponding method.
     * 
     * @param $controller Object Controller of original request
     * @return void
     */
    function dispatch(&$controller) {
 
        $this->controller =& $controller;
 
        $controllerName = $this->controller->name;
        $actionName = $this->controller->action;
 
        $functionName = Inflector::variable(str_replace('api', Inflector::variable($controllerName), $actionName));
 
        //Check that method exists
        if (method_exists($this, $functionName)) {
            $this->$functionName();
        }
        else {
            $this->cakeError('apiError', array('apiErrorCode'=>1003));
        }
 
        //Render if it hasn't already done so
        if (!$this->rendered) {
            $this->render();
        }
    }
}

The API error related code is not important at this stage, I've included it for simplicity. Most important to note that dispatch() executes a method based on the request and render() grabs the correct view also based on that request. The render() method requires the RequestHandler component to be activated for the controller that is stored in $this->controller.

Also, render() will look for view files in the previous versions of the API, if for instance get.ctp is missing for the 0.3 API, render() will look to use the get.ctp file from API 0.2. I will get into the structure of view files later and this will make more sense. I'm not entirely sure its a good idea to do this, but it saves us from creating a whole new set of view files for each new version of the API.

The versioned API classes that extend the base class live at vendors/0.2/api.php substituting 0.2 for each particular version obviously. Each version has control over which methods it implements and how, each new version can extend the previous version so that all previous API methods are inherited.

App::import('Vendor', 'api/api');
class Api_0_3 extends Api  
{
    public $version = 0.3;
 
    function postsGet() {
        //Get posts, store them in an array and set them for the view?
        //Whatever code would go into the api_get() method in the posts controller, should go in here
        //With only one minor alteration, calls to $this-> should be replaced with $this->controller->
 
        $posts = $this->controller->Post->find('all', array(
            'contain'=>false
        ));
        $this->controller->set(compact('posts'));
    }
}

The Views

The view folder structure is fairly straight forward, the views continue to be stored in the views folder for the corresponding controller and continue to use the folder naming convention for the RequestHandler component:

http://yoursite.com/0.2/posts/get.xml = /views/posts/api/0.2/xml/get.ctp

http://yoursite.com/0.2/posts/get.json = /views/posts/api/0.2/json/get.ctp

http://yoursite.com/0.3/posts/get.xml = /views/posts/api/0.3/xml/get.ctp

If when creating a 0.3 version of the API you want to keep the same view that was used in 0.2 you do not need to create a new view file specifically for the 0.3 version, the 0.2 version of the file will be rendered automatically (see the API class render() method above). I'm not certain this is the best idea, but in the mean time it minimises replicating views unnecessarily.