Teaching the Zend REST server to talk JSON
Over the last couple of days I safaried through the REST component of the Zend Framework to illmuniate the options to provide a public API for on the framework based applications, using a REST architectural style. As I was unhappy that the Zend Framework REST server per default only responds in POX(Plain old XML), a bend over from XML to the 'fat-free' JSON format was favoured to reduce the client sided parsing effort. The first part of this article will quide you through the creation of the responsive part of a RESTful public API and continue to travel the path to achieve the articles purpose.
This article is based upon on some previous work of Pádraic Brady who is covering "RESTful Web Services with Zend Framework" and of Senthil Nathan et al. exploring how to "Convert XML to JSON in PHP".
Spotting the URL-addressable resources
The assumed example use cases or URL-addressable resources for access through the public API are outlined in the following image. As you wil notice the basic HTTP methods, the ones in the Resource interface, are aligned to the CRUD actions of the application. As the main focus of this article is on the responsive aspects of RESTful web services, the delete, update and create mechanisms for resources will be covered in an upcoming article.
Setting up the infrastructure
To confine the public API clearly from other parts of the application and to provide a single point of entry, a new module and use case specific routings towards them need to be added to the applications bootstrap file. The following code shows the additonal setup, as the use case specific routings will grow over a longer period it's recommended to swap them out into a configuration file section and load them via Zend_Config.
<?php
....
$router = new Zend_Controller_Router_Rewrite();
// Load routings
$config = new Zend_Config_Ini('../application/config/config.ini', 'public-api');
$router->addConfig($config, 'routes');
// Add directories to include path to load them via Zend::loadClass
$includePath = get_include_path();
...
$includePath.= PATH_SEPARATOR . $config->dir->public->api->models;
$includePath.= PATH_SEPARATOR . $config->dir->public->api->services;
$includePath.= PATH_SEPARATOR . $config->dir->public->api->controllers;
...
set_include_path($includePath);
...
// Configure the front controller
$controller->setControllerDirectory(array('default' => '../application/default/controllers',
'api' => '../application/api/controllers'))
->setRouter($router)
->setParam('useModules', true)
->throwExceptions(true);
...
?>
Here is a snapshot from the current applications configuration file, defining the example use cases routings in an own section and adding the related directories of the public API to the development section.
[development]
...
dir.public.api.models = C:/Apache2.2/htdocs/recordshelf/application/api/models
dir.public.api.services = C:/Apache2.2/htdocs/recordshelf/application/api/service-providers
dir.public.api.controllers = C:/Apache2.2/htdocs/recordshelf/application/api/controllers
...
[public-api]
routes.recordshelfByUser.route = "api/:controller/user/:user-identifier"
routes.recordshelfByUser.defaults.module = "api"
routes.recordshelfByUser.defaults.controller = "recordshelf"
routes.recordshelfByUser.defaults.action = "user"
routes.recordshelfByShelf.route = "api/:controller/shelf/:shelf-identifier"
routes.recordshelfByShelf.defaults.module = "api"
routes.recordshelfByShelf.defaults.controller = "recordshelf"
routes.recordshelfByShelf.defaults.action = "shelf"
routes.recordshelfByGenre.route = "api/:controller/genre/:genre-identifier"
routes.recordshelfByGenre.defaults.module = "api"
routes.recordshelfByGenre.defaults.controller = "recordshelf"
routes.recordshelfByGenre.defaults.action = "genre"
...
In addition the directory structure of the application needs to be extended by adding an api directory under the application directory, where all the public API and REST server related code will be placed. The additional directories also need to be added to the include_path in the applications bootstrap file. This leads to the following directory structure and a clean separation from other 'classic' application parts.
recordshelf/
/application
/default --> 'classic' application
/controllers
/models
/views
/api --> public API code artifacts
/controllers
/models
/service-providers
/config --> public API routing defintions
...
Crafting the public API machinery
With all this done, the access to the public API has now a single entry point at
http://domain.org/api/
and we can now move on to implement one of the assumed use cases, the one who is colored green in the above diagram. On the root entry level, it would be a good practice to provide a summary of the provided API and their supported methods for service consumers. To do so we use the main IndexController responding to direct calls of http://domain.org/api/
to generate a static HTML file providing this orientational information, this approach can even broaded down to each resource providing its own API documentation.I will start the following bulk of code fragments with the main action methods of the Api_RecordshelfController class located in the controllers directory of the public API. This class is responsible for providing the known action methods, initializing and configuring the Zend_Rest_Server and hooking in the suitable service provider.
<?php
class Api_RecordshelfController extends Zend_Controller_Action {
/**
* Get view for resource specific API documentation and render it
*/
public function indexAction() {
...
}
public function userAction() {
...
}
public function shelfAction() {
$shelfIdentifier = $this->getRequest()->getParam('shelf-identifier');
if($this->getRequest()->getMethod() === 'GET') {
// Define mapping to service method in service provider class
$request = array('method' => 'getShelfByShelf',
'shelfIdentifier' => $shelfIdentifier);
$server = $this->getRestServerInstance();
$xmlResponse = $server->handle($request);
$this->getResponse()->setHeader('Content-Type', 'text/xml')
->setBody($xmlResponse);
}
}
public function genreAction() {
...
}
public function __call($methodName, $args) {
$logger = Zend_Registry::get('logger');
$logger->info("Unkown action requested ".get_class($this)."::{$methodName}");
$this->indexAction();
}
/**
* Initialising, configuring the REST server and injecting the service provider.
*/
private function getRestServerInstance() {
$restServerInstance = new Zend_Rest_Server();
// Injecting service provider
$restServerInstance->setClass('Recordshelf_Service_Provider');
// Switching off auto-response
$restServerInstance->returnResponse(true);
return $restServerInstance;
}
}
The service provider Recordshelf_Service_Provider class is responsible for querying the database model and is using the Zend_Db_Table Relationships abilities. The getShelfByShelf method is called by the Zend_Rest server via Reflection and is actually doing all the work for the assumed use case scenario.
<?php
class Recordshelf_Service_Provider {
private $responseVault = null;
/**
* Get all shelf related data.
* @param mixed The shelf identifier, either an id or the name of the shelf.
* @return mixed A collection containing the shelf details and the assigned
* records or a custom error status on failure.
*/
public function getShelfByShelf($shelfIdentifier) {
$responseCollection = array();
$table = new Shelfs();
if(ctype_digit($shelfIdentifier)) {
$where = $table->getAdapter()->quoteInto('id = ?', $shelfIdentifier);
} elseif(is_string($shelfIdentifier)) {
$where = $table->getAdapter()->quoteInto('name = ?', $shelfIdentifier);
}
$shelfsRowset = $table->fetchRow($where);
if($shelfsRowset === null) {
return array('msg' => "Unknown shelf identifier {$shelfIdentifier} passed",
'status' => false);
}
$ownerOfShelf = $shelfsRowset->findUsers();
$ownerOfShelf = $ownerOfShelf->current()->toArray();
$genreOfShelf = $shelfsRowset->findGenres();
$genreOfShelf = $genreOfShelf->current()->toArray();
$shelf = $shelfsRowset->toArray();
$shelf['genre'] = $genreOfShelf['name'];
$shelf['owner'] = $ownerOfShelf['name'];
$recordsOfShelf = $shelfsRowset->findRecordsOfShelfs();
$records = array();
foreach($recordsOfShelf as $recordOfShelf) {
$recordsRowset = $recordOfShelf->findRecords();
$recordsRowset = $recordsRowset->current();
$genresRowset = $recordsRowset->findGenres();
$genreOfRecord = $genresRowset->current()->toArray();
$typesRowset = $recordsRowset->findTypes();
$typeOfRecord = $typesRowset->current()->toArray();
$additionalDetails = array('genre' => $genreOfRecord['name'],
'recordtype' => $typeOfRecord['type']);
$records[] = array_merge($recordsRowset->toArray(),
$additionalDetails);
}
$shelf['items'] = count($records);
$responseCollection['recordshelf'] = $shelf;
$responseCollection['records'] = $records;
return $responseCollection;
}
public function getShelfsByGenre($gernreIdentifier) {
...
}
public function getShelfsByUser($userIdentifier) {
...
}
}
As the server is now working, we can take a glance at the generated response of the REST server component by making a simple browser request to
http://domain.org/api/recordshelf/shelf/11
or to http://domain.org/api/recordshelf/shelf/Classic material
. If you are not comfortable with the generated response format but want to stick with XML it is possible to define custom XML responses, by returning a modifiedDOMDocument, DOMElement or SimpleXMLElement object from the service providing methods.<?xml version="1.0" encoding="UTF-8"?>
<Recordshelf_Service_Provider generator="zend" version="1.0">
<getShelfByShelf>
<recordshelf>
<id>11</id>
<name>Classic material</name>
<description>Classic rap releases from the golden area still causing lots of speaker damage.</description>
<userid>1</userid>
<genreid>15</genreid>
<genre>Rap</genre>
<owner>Kool Dj Red Alert</owner>
<items>7</items>
</recordshelf>
<records>
<key_0>
<id>1</id>
<artistname>Gang Starr</artistname>
<label>Chrysalis(EMI)</label>
<recordtypeid>1</recordtypeid>
<releasename>Step in the arena</releasename>
<releaseyear>1991</releaseyear>
<genreid>15</genreid>
<genre>Rap</genre>
<recordtype>LP</recordtype>
</key_0>
...
<key_6>
<id>7</id>
<artistname>Jeru the Damaja</artistname>
<label>Payday Records</label>
<recordtypeid>4</recordtypeid>
<releasename>The Sun Rises in the East</releasename>
<releaseyear>1994</releaseyear>
<genreid>15</genreid>
<genre>Rap</genre>
<recordtype>2xLP</recordtype>
</key_6>
</records>
<status>success</status>
</getShelfByShelf>
</Recordshelf_Service_Provider>
Finally bending over from XML to JSON
The next and final task would be to transform the former XML approach into an JSON approach, where the response is send in JSON instead of XML. Through some research I found a tailor-made article over at IBM developers work, which sums up the steps and provides a class to achieve the data format transformation. The class in the referred article is also a Zend Framework proposal, that hopefully will make it in the core library. In case you are interested or limited to deliver JSON in a XML namespace and attribute preserving way, you might consider the BadgerFish transformation convention invented by David Sklar, who also provides a ready to go class handling these transformations.
Here is a little rearranged version of the proposal class from Senthil Nathan to achieve the transformation,which should be located in the applications custom library directory. Further a static indent method was added, which is based on a contribution to the PHP manual, to make the responses more readable for humans.
<?php
class Recordshelf_Json extends Zend_Json {
protected static $maxRecursionDepthAllowed = 25;
public static function fromXML ($xmlStringContents, $ignoreXmlAttributes=true) {
if ((is_string($xmlStringContents) == false) || (is_bool($ignoreXmlAttributes) == false)) {
throw new Zend_Json_Exception('Function fromXML was called with invalid parameter(s).');
}
// Load the XML formatted string into a Simple XML Element object.
$simpleXmlElementObject = simplexml_load_string($xmlStringContents);
// If it is not a valid XML content, throw an exception.
if ($simpleXmlElementObject == null) {
throw new Zend_Json_Exception('Function fromXML was called with an invalid XML formatted string.');
}
$resultArray = null;
try {
$resultArray = self::_processXML($simpleXmlElementObject, $ignoreXmlAttributes);
} catch (Exception $e) {
// Rethrow the same exception.
throw($e);
}
$jsonStringOutput = self::encode($resultArray);
return($jsonStringOutput);
}
// comes from a comment in the php manuel for the json_decode function
public static indent($json) {
$indentedJson = '';
$identPos = 0;
$jsonLength = strlen($json);
for($i = 0; $i <= $jsonLength; $i++) {
$_char = substr($json, $i, 1);
if($_char == '}' || $_char == ']') {
$indentedJson .= chr(13);
$identPos --;
for($ident = 0;$ident < $identPos;$ident++) {
$indentedJson .= chr(9);
}
}
$indentedJson .= $_char;
if($_char == ',' || $_char == '{' || $_char == '[') {
$indentedJson .= chr(13);
if($_char == '{' || $_char == '[') {
$identPos ++;
}
for($ident = 0;$ident < $identPos;$ident++) {
$indentedJson .= chr(9);
}
}
}
return $indentedJson;
}
protected static function _processXML ($simpleXmlElementObject,
$ignoreXmlAttributes,
$recursionDepth = 0) {
// Keep an eye on how deeply we are involved in recursion.
if ($recursionDepth > self::$maxRecursionDepthAllowed) {
// XML tree is too deep. Exit now by throwing an exception.
throw new Zend_Json_Exception("Function _processXML exceeded the allowed recursion depth of " .
self::$maxRecursionDepthAllowed);
}
if ($recursionDepth == 0) {
/* Store the original SimpleXmlElementObject sent by the caller.
We will need it at the very end when we return from here for good.*/
$callerProvidedSimpleXmlElementObject = $simpleXmlElementObject;
}
if (get_class($simpleXmlElementObject) == "SimpleXMLElement") {
// Get a copy of the simpleXmlElementObject
$copyOfSimpleXmlElementObject = $simpleXmlElementObject;
// Get the object variables in the SimpleXmlElement object for us to iterate.
$simpleXmlElementObject = get_object_vars($simpleXmlElementObject);
}
// It needs to be an array of object variables.
if (is_array($simpleXmlElementObject)) {
$resultArray = array();
// Is the input array size 0? Then, we reached the rare CDATA text if any.
if (count($simpleXmlElementObject) <= 0) {
/* Let us return the lonely CDATA. It could even be
an empty element or just filled with whitespaces.*/
return (trim(strval($copyOfSimpleXmlElementObject)));
}
// Let us walk through the child elements now.
foreach($simpleXmlElementObject as $key => $value) {
/* Check if we need to ignore the XML attributes.
If yes, you can skip processing the XML attributes.
Otherwise, add the XML attributes to the result array.*/
if(($ignoreXmlAttributes == true) && ($key == "@attributes")) {
continue;
}
/* Let us recursively process the current XML element we just visited.
Increase the recursion depth by one.*/
$recursionDepth++;
$resultArray[$key] = self::_processXML ($value, $ignoreXmlAttributes, $recursionDepth);
// Decrease the recursion depth by one.
$recursionDepth--;
}
if ($recursionDepth == 0) {
/* That is it. We are heading to the exit now.
Set the XML root element name as the root [top-level] key of
he associative array that we are going to return to the original
caller of this recursive function.*/
$tempArray = $resultArray;
$resultArray = array();
$resultArray[$callerProvidedSimpleXmlElementObject->getName()] = $tempArray;
}
return($resultArray);
} else {
/* We are now looking at either the XML attribute text or
the text between the XML tags. */
return (trim(strval($simpleXmlElementObject)));
}
}
}
Finally to achieve the public API response format transformation from XML to JSON the shelfAction method in the Api_RecordshelfController class has to be altered like shown in the following code extract.
<?php
...
public function shelfAction() {
$shelfIdentifier = $this->getRequest()->getParam('shelf-identifier');
if($this->getRequest()->getMethod() === 'GET') {
// Define mapping to service method in service provider class
$request = array('method' => 'getShelfByShelf',
'shelfIdentifier' => $shelfIdentifier);
$server = $this->getRestServerInstance();
$xmlResponse = $server->handle($request);
// Transfrom XML to JSON
$jsonContent = Recordshelf_Json::fromXML($xmlResponse);
// For AJAX requests
if($this->getRequest()->isXmlHttpRequest()) {
$contentType = 'application/json';
} else {
$contentType = 'text/plain';
}
$this->getResponse()->setHeader('Content-Type', $contentType)
->setBody(Recordshelf_Json::indent($jsonContent));
}
}
...
The final and last code listing shows the former example response in the preferred JSON format allowing much easier response handling on the browser side of an application.
{
"Recordshelf_Service_Provider":{
"getShelfByShelf":{
"recordshelf":{
"id":"11",
"name":"Classic material",
"description":"Classic rap releases from the ...",
"userid":"1",
"genreid":"15",
"genre":"Rap",
"owner":"Kool Dj Red Alert",
"items":"7"
},
"records":{
"key_0":{
"id":"1",
"artistname":"Gang Starr",
"label":"Chrysalis(EMI)",
"recordtypeid":"1",
"releasename":"Step in the arena",
"releaseyear":"1991",
"genreid":"15",
"genre":"Rap",
"recordtype":"LP"
},
...
"key_6":{
"id":"7",
"artistname":"Jeru the Damaja",
"label":"Payday Records",
"recordtypeid":"4",
"releasename":"The Sun Rises in the East",
"releaseyear":"1994",
"genreid":"15",
"genre":"Rap",
"recordtype":"2xLP"
}
},
"status":"success"
}
}
}
3 comments:
Is this a bad time to tell you about Zend_Json_Server? It is in the incubator and has been since I wrote Zend_Rest_Server. It's almost identical to Zend_Rest_Server except that it outputs directly to JSON instead of XML :)
- Davey
Damn! Davey thanks for the 'late' hint. I guess missed that one at research. At least it thaught me lots regarding routing and Zend_Db_Table realtionships.
Another small hint: Zend_Controller_Action_ContextSwitch
It will allow you to define any output based on a format URI parameter. This way you actually won't have to use a separate api module, instead you can just use context switching to define whether to output xml, json or plain html.
Makes Zend_Rest_Server absolete.
Post a Comment