>/D_
Published on

Rolling out an API for your cakePHP app Part 3: Handling Errors

Authors
  • avatar
    Name
    Frank
    Twitter

The last posts in this series covered the a basic architecture of an API in cakePHP, the limitations of that basic architecture and a possible solution using an API component and some external API classes. The last part is the handling of API specific errors. The goals for the error handling system:

  • Return specific error codes for API errors
  • Control over header response codes sent
  • Ability to version the responses
  • Return an error in the format of the initial request

[ad]
We wanted to return custom error codes for particular API errors, this makes for a more simple and robust error reporting system especially if error messages will be localized at some stage. The inspiration for this came from the facebook and digg APIs.

Error Handling Class

To handle errors I use a custom error handling class written by primeminister, its best to start with his post and read through before continuing. I add a few methods to the app_error class to throw an error and pass extra parameters to primeminister's error() method, and to render the API error using the correct version of the view.

<?php
App::import('Vendor', 'api/api');
class AppError extends ErrorHandler {
 
private $apiError = false;
 
 /**
 * HTTP response codes array
 *
 * @var array
 * @see http://en.wikipedia.org/wiki/List_of_HTTP_status_codes
 **/
var $codes = array(
    200 => 'OK',
    400 => 'Bad Request',
    401 => 'Unauthorized',
    402 => 'Payment Required',
    403 => 'Forbidden',
    404 => 'Not Found',
    405 => 'Method Not Allowed',
    406 => 'Not Acceptable',
    407 => 'Proxy Authentication Required',
    408 => 'Request Time-out',
    500 => 'Internal Server Error',
    501 => 'Not Implemented',
    502 => 'Bad Gateway',
    503 => 'Service Unavailable',
    504 => 'Gateway Time-out'
);
 
/**
 * Class constructor, overloading to make minor ammendment.
 *
 * @param string $method Method producing the error
 * @param array $messages Error messages
 */
function __construct($method, $messages) {
    App::import('Core', 'Sanitize');
    static $__previousError = null;
 
    if ($__previousError != array($method, $messages)) {
        $__previousError = array($method, $messages);
        $this->controller =& new CakeErrorController();
    } else {
        $this->controller =& new Controller();
        $this->controller->viewPath = 'errors';
    }
 
    $options = array('escape' => false);
    $messages = Sanitize::clean($messages, $options);
 
    if (!isset($messages[0])) {
        $messages = array($messages);
    }
 
    if (method_exists($this->controller, 'apperror')) {
        return $this->controller->appError($method, $messages);
    }
 
    if (!in_array(strtolower($method), array_map('strtolower', get_class_methods($this)))) {
        $method = 'error';
    }
 
    //Need to ammend below so that custom methods are triggered in production environments
    //if ($method !== 'error') {
    if (!in_array($method, get_class_methods('AppError'))) {
        if (Configure::read() == 0) {
            $method = 'error404';
            if (isset($code) && $code == 500) {
                $method = 'error500';
            }
        }
    }
    $this->dispatchMethod($method, $messages);
    $this->_stop();
}
 
/**
 * Error
 *
 * Handles the error with the right response code
 *
 * @param array $params
 * @return void
 * @author primeminister
 * @access public
 * @see http://apiwiki.twitter.com/REST+API+Documentation#HTTPStatusCodes
 */
public function error($params) {
 
    extract($params, EXTR_OVERWRITE);
    if (!isset($name)) {
        $name = $this->codes[$code];
    }
    if (!isset($url)) {
        $url = $this->controller->here;
    }
    // set header
    header("HTTP/1.x $code $name");
    $this->controller->set(array(
        'code'      => $code,
        'name'      => $name,
        'message'   => $message,
        'title'     => $code . ' ' . $name,
        'request'   => $url
    ));
 
 
    //If API error handle in a different way
    if ($this->apiError) {
        $this->controller->set(compact('apiErrorCode'));
        $this->renderApiError();
    }
    else {
        if ($this->controller->RequestHandler->isXml()) {
            $this->controller->RequestHandler->renderAs($this->controller, 'xml');
        }
 
        $this->_outputMessage('error');
    }
}
 
/**
 * Throw an API error, additionally passing an api error code.
 * 
 * @param Array $data Data sent by calling method, optionally includes apiErrorCode
 * 
 * @return void
 */
function apiError($data) {
 
    $this->apiError = true;
 
    //Get correct message and code based on API error code
    $apiErrorCode = 500;
    if (isset($data['apiErrorCode'])) {
        $apiErrorCode = $data['apiErrorCode'];
    }
 
    $api = new Api();
    $errorData = $api->getError($apiErrorCode);
 
    $code = (isset($errorData['httpCode']))?$errorData['httpCode']:500;
 
    $message = __('API error.', true);
    if (isset($errorData['errorMessage'])) {
        $message = $errorData['errorMessage'];
    }
    elseif (in_array($apiErrorCode, array_keys($this->codes))) {
        $message = $this->codes[$apiErrorCode];
    }
 
    $this->error(array('code'=>$code,'message'=>$message, 'apiErrorCode'=>$apiErrorCode));
}
 
/**
 * Render an API error using correct error view based on current API version.
 * 
 * @return void
 */
function renderApiError() {
 
    //Retrieve the correct view
    $version = '0.3';
    $folder = $this->controller->RequestHandler->prefers();
    $view = 'error';
 
    if (isset($this->controller->params['version'])) {
        $version = $this->controller->params['version'];
    }
 
    if ($this->controller->RequestHandler->isXml()) {
        $this->controller->RequestHandler->renderAs($this->controller, 'xml');
    }
 
    $errorFile = new File(VIEWS . "/errors/api/$version/$folder/error.ctp");
    if ($errorFile->exists()) {
        $view = "/errors/api/$version/$folder/error";
    }
    else {
 
        //Get error view for previous API version
        $version = substr(strstr($version, '.'), 1);
 
        //NOTE: again please note that the version of my API started at 0.3, you might need to alter the for loop below for your case
        for ($i = $version; $i >= 3; $i--) {
            $viewFile = new File(VIEWS . "/errors/api/0.$i/$folder/error.ctp");
            if ($viewFile->exists()) {
                $view = "/errors/api/0.$i/$folder/error";
                break;
            }
        }
    }
    $this->_outputMessage($view); 
}
}

The error codes and the method of retrieving those codes live in the API class:

    /**
     * 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;
    }

Throwing an error is now fairly straight forward:

$this->cakeError('apiError', array('apiErrorCode'=>1001));

The resulting response is a 500 error with the xml content:

<Error Code="1001" Request="/0.3/posts/get.xml">Version does not exist.</Error>

Error Response Views

In order to version the responses in accordance with the API version, the views are ordered into folders based on API version and response type. The view for the error above lives at: /views/errors/api/0.3/xml/error.ctp. The view itself:

<Error Code="<?php echo h($apiErrorCode) ?>" Request="<?php echo h($request) ?>">
    <?php echo h($message) ?>
</Error>

Final Word

That wraps up my API solution architecture. For the large part it accomplishes the goals I set for it and I'm happy with the result, feedback is always welcomed.