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

by frank on April 30, 2010

in PHP

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.

<?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.

<?php
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.

Share and Enjoy:
  • Digg
  • Sphinn
  • del.icio.us
  • Facebook
  • Google Bookmarks
  • email
  • Reddit
  • StumbleUpon

Profile:  Frank has been programming for the web using PHP, Javascript and numerous libraries and frameworks for the past 6 years. More articles.

{ 10 comments… read them below or add one }

SteveNo Gravatar June 10, 2010 at 12:06 pm

Frank,

Thanks a bunch for this code, very interesting idea! I’m just a little confused about one thing… It seems that this will keep controller-action logic and views separated into versions, but what about Models and database versioning? The posts controller in your example would still be tied to its Model regardless of version. Am I correct, or am I missing something?

Thanks again.
Steve

frankNo Gravatar June 10, 2010 at 1:10 pm

Hey Steve,

Basically all I am versioning is the API with this solution. You’re correct, only the controllers and views are versioned – thats all I’m concerned about regarding the API. Model and database versioning is not really an issue for us and kind of outside the scope of this work on the API – I haven’t even thought about versioning either model or database tbh :-)

HTH
F.

SteveNo Gravatar June 11, 2010 at 2:45 am

Frank,

Thanks for responding! I really do like what you’ve done. I was also wondering if you implemented any special authentication for this API. I’m building an API right now and, I’m really stuck on authentication… It looks like the best CakePHP has built in is digest, but what about something like what Amazon does: http://docs.amazonwebservices.com/AmazonS3/2006-03-01/index.html?RESTAuthentication.html , or maybe oAuth? If you could offer any advice, I would greatly appreciate it!
Thanks again,

Steve

frankNo Gravatar June 11, 2010 at 7:31 pm

Hey Steve,

At the moment our authentication is very basic, we just test an apikey passed in the GET string in the app_controller before filter. At some point I was thinking about using oAuth but thats about as far as I have got on it.

Cheers,
Frank.

SteveNo Gravatar June 12, 2010 at 1:55 am

Frank,

Thansk again for the resonse.

Steve

BartNo Gravatar June 21, 2010 at 11:09 pm

Hi Frank,

thanks for the inspiration. Versioning was just the thing missing in my api. However, upon implementation of your solution, I keep getting the 1002-error: Unknown output file. As far as I can tell, all the files are in their right place.. Got any tips as to where I could find the solution?

cheers,

b

BartNo Gravatar June 21, 2010 at 11:20 pm

Hi Frank. Never mind. I missed a double extension in my view.

bartNo Gravatar June 21, 2010 at 11:34 pm

I do however have one other question. How do you pass variables into the api? E.g. get /0.2/posts/get/1.xml with 1 being the $id of the post? They keep getting lost when I try to pass them into the api.

frankNo Gravatar June 22, 2010 at 8:44 am

You have to do something like: /0.2/posts/get.xml?id=1 this solution wasn’t structured to allow passing args like you normally would in cakephp.

bartNo Gravatar June 22, 2010 at 7:57 pm

Hi Frank, I fixed it by adjusting the routing and slightly changing the api-code so the arguments are passed through in an array..

// process arguments
if (isset($this->controller->params['pass'])) {
foreach ($this->controller->params['pass'] as $key=>$value) {
$argument[$key] = $value;
}
}

Leave a Comment

Previous post:

Next post: