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.
{ 10 comments… read them below or add one }
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
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.
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
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.
Frank,
Thansk again for the resonse.
Steve
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
Hi Frank. Never mind. I missed a double extension in my view.
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.
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.
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;
}
}