├── .gitignore ├── README.md ├── composer.json ├── config └── application.config.php ├── init_autoloader.php ├── module └── AlbumApi │ ├── Module.php │ ├── config │ └── module.config.php │ └── src │ └── AlbumApi │ └── Controller │ ├── AbstractRestfulJsonController.php │ ├── AlbumController.php │ └── IndexController.php └── public ├── .htaccess └── index.php /.gitignore: -------------------------------------------------------------------------------- 1 | ._* 2 | .~lock.* 3 | .DS_Store 4 | composer.lock 5 | composer.phar 6 | vendor -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZF2 Restful API Example 2 | 3 | This is an example application showing how to create a RESTful JSON API using PHP and [Zend Framework 2](http://framework.zend.com/). It starts from a clean base rather than the skeleton app as that includes a load of files which are unnecessary for an API (language and html view files). Matches the [ZF2 Album example](http://framework.zend.com/manual/2.2/en/user-guide/overview.html) but without the DB logic for simplicity. Examples of how to test the application will be included in part 2. 4 | 5 | ## Requirements 6 | 7 | * PHP 5.3+ 8 | * Web server [setup with virtual host to serve project folder](http://framework.zend.com/manual/2.2/en/user-guide/skeleton-application.html#virtual-host) 9 | * [Composer](http://getcomposer.org/) (manage dependencies) 10 | 11 | ## Creating the API 12 | 13 | 1. Get composer: 14 | 15 | ``` 16 | curl -sS https://getcomposer.org/installer | php 17 | ``` 18 | 19 | 2. Create the composer.json file to get ZF2: 20 | 21 | ``` 22 | { 23 | "require": { 24 | "php": ">=5.3.3", 25 | "zendframework/zendframework": ">=2.2.4" 26 | } 27 | } 28 | ``` 29 | 30 | 3. Install the dependencies: 31 | 32 | ``` 33 | php composer.phar install 34 | ``` 35 | 36 | 4. public/index.php (for directing calls to Zend and static) 37 | 38 | ``` 39 | run(); 49 | ``` 50 | 5. public/.htaccess (for redirecting non-asset requests to index.php) 51 | 52 | ``` 53 | RewriteEngine On 54 | # The following rule tells Apache that if the requested filename 55 | # exists, simply serve it. 56 | RewriteCond %{REQUEST_FILENAME} -s [OR] 57 | RewriteCond %{REQUEST_FILENAME} -l [OR] 58 | RewriteCond %{REQUEST_FILENAME} -d 59 | RewriteRule ^.*$ - [NC,L] 60 | # The following rewrites all other queries to index.php. The 61 | # condition ensures that if you are using Apache aliases to do 62 | # mass virtual hosting, the base path will be prepended to 63 | # allow proper resolution of the index.php file; it will work 64 | # in non-aliased environments as well, providing a safe, one-size 65 | # fits all solution. 66 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$ 67 | RewriteRule ^(.*) - [E=BASE:%1] 68 | RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L] 69 | ``` 70 | 71 | 6. init_autoloader.php (for loading Zend) 72 | 73 | ``` 74 | add('Zend', $zf2Path); 79 | 80 | if (!class_exists('Zend\Loader\AutoloaderFactory')) { 81 | throw new RuntimeException('Unable to load ZF2. Run `php composer.phar install` or define a ZF2_PATH environment variable.'); 82 | } 83 | ``` 84 | 85 | 7. config/application.config.php (application wide configuration) 86 | 87 | ``` 88 | array( 91 | 'AlbumApi', 92 | ), 93 | 'module_listener_options' => array( 94 | 'module_paths' => array( 95 | './module', 96 | './vendor', 97 | ), 98 | // local/global config location when needed 99 | //'config_glob_paths' => array( 100 | // 'config/autoload/{,*.}{global,local}.php', 101 | //), 102 | ), 103 | ); 104 | ``` 105 | 106 | 8. module/AlbumApi/Module.php (module setup) 107 | 108 | ``` 109 | getApplication()->getEventManager(); 121 | $moduleRouteListener = new ModuleRouteListener(); 122 | $moduleRouteListener->attach($eventManager); 123 | } 124 | 125 | public function getConfig() 126 | { 127 | return include __DIR__ . '/config/module.config.php'; 128 | } 129 | 130 | public function getAutoloaderConfig() 131 | { 132 | return array( 133 | 'Zend\Loader\StandardAutoloader' => array( 134 | 'namespaces' => array( 135 | __NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__, 136 | ), 137 | ), 138 | ); 139 | } 140 | } 141 | 142 | ``` 143 | 144 | 9. module/AlbumApi/config/module.config.php (module configuration) 145 | 146 | ``` 147 | array( 151 | 'routes' => array( 152 | 'home' => array( 153 | 'type' => 'Zend\Mvc\Router\Http\Literal', 154 | 'options' => array( 155 | 'route' => '/', 156 | 'defaults' => array( 157 | 'controller' => 'AlbumApi\Controller\Index', 158 | ), 159 | ), 160 | ), 161 | ), 162 | ), 163 | 'controllers' => array( 164 | 'invokables' => array( 165 | 'AlbumApi\Controller\Index' => 'AlbumApi\Controller\IndexController' 166 | ), 167 | ), 168 | 'view_manager' => array( 169 | 'strategies' => array( 170 | 'ViewJsonStrategy', 171 | ), 172 | ), 173 | ); 174 | ``` 175 | 176 | 10. module/AlbumApi/src/AlbumApi/Controller/IndexController.php (basic RESTful controller) 177 | 178 | ``` 179 | "Welcome to the Zend Framework Album API example")); 190 | } 191 | } 192 | ``` 193 | 194 | 11. You should now be able to request the API URL and receive a JSON response with the welcome message 195 | 196 | 12. module/AlbumApi/src/AlbumApi/Controller/AlbumController.php (album controller with CRUD REST actions) 197 | 198 | ``` 199 | 211 | array( 212 | array('id'=> 1, 'name' => 'Mothership', 'band' => 'Led Zeppelin'), 213 | array('id'=> 2, 'name' => 'Coda', 'band' => 'Led Zeppelin'), 214 | ) 215 | ) 216 | ); 217 | } 218 | 219 | public function get($id) 220 | { // Action used for GET requests with resource Id 221 | return new JsonModel(array("data" => array('id'=> 2, 'name' => 'Coda', 'band' => 'Led Zeppelin'))); 222 | } 223 | 224 | public function create($data) 225 | { // Action used for POST requests 226 | return new JsonModel(array('data' => array('id'=> 3, 'name' => 'New Album', 'band' => 'New Band'))); 227 | } 228 | 229 | public function update($id, $data) 230 | { // Action used for PUT requests 231 | return new JsonModel(array('data' => array('id'=> 3, 'name' => 'Updated Album', 'band' => 'Updated Band'))); 232 | } 233 | 234 | public function delete($id) 235 | { // Action used for DELETE requests 236 | return new JsonModel(array('data' => 'album id 3 deleted')); 237 | } 238 | } 239 | ``` 240 | 241 | 13. Update module/AlbumApi/config/module.config.php to add controller and routing 242 | 243 | ``` 244 | array( 248 | 'routes' => array( 249 | 'home' => array( 250 | 'type' => 'Zend\Mvc\Router\Http\Literal', 251 | 'options' => array( 252 | 'route' => '/', 253 | 'defaults' => array( 254 | 'controller' => 'AlbumApi\Controller\Index', 255 | ), 256 | ), 257 | ), 258 | 'album' => array( 259 | 'type' => 'segment', 260 | 'options' => array( 261 | 'route' => '/album[/:id]', 262 | 'constraints' => array( 263 | 'id' => '[0-9]+', 264 | ), 265 | 'defaults' => array( 266 | 'controller' => 'AlbumApi\Controller\Album', 267 | ), 268 | ), 269 | ), 270 | ), 271 | ), 272 | 'controllers' => array( 273 | 'invokables' => array( 274 | 'AlbumApi\Controller\Index' => 'AlbumApi\Controller\IndexController', 275 | 'AlbumApi\Controller\Album' => 'AlbumApi\Controller\AlbumController', 276 | ), 277 | ), 278 | 'view_manager' => array( 279 | 'strategies' => array( 280 | 'ViewJsonStrategy', 281 | ), 282 | ), 283 | ); 284 | ``` 285 | 286 | 14. You can now make specific HTTP requests to the album resource to interact with it in a RESTful manner, e.g. GET /album to see the list of albums, PUT /album/3 to update album 3. Use a REST client, like Chromes Postman to test it out 287 | 288 | ## Database Integration 289 | 290 | To add the album DB functionality have a look at the tutorial [database and models](http://framework.zend.com/manual/2.2/en/user-guide/database-and-models.html) section, add the DB classes and configuration and plug them into the controllers. Always return the results as a JsonModel with an associative array representation of the Model objects to ensure they can be serialised. 291 | 292 | ## Tricks and traps 293 | 294 | ### Error handling for application exceptions and 404s 295 | 296 | If your application experiences an error or can't find a requested resource you want to return a meaningful response. Currently if an error occurs you'll get a Zend\View\Exception\RuntimeException 'Unable to render template "error"', cause it can't find the html error view. To fix this you need to hook into the ZF2 eventmanager events and intercept errors, returning an appropiate response. 297 | 298 | You do this in the module Module.php file: 299 | 300 | ``` 301 | getApplication()->getEventManager(); 314 | $moduleRouteListener = new ModuleRouteListener(); 315 | $moduleRouteListener->attach($eventManager); 316 | 317 | $eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, array($this, 'onDispatchError'), 0); 318 | $eventManager->attach(MvcEvent::EVENT_RENDER_ERROR, array($this, 'onRenderError'), 0); 319 | } 320 | 321 | public function onDispatchError($e) 322 | { 323 | return $this->getJsonModelError($e); 324 | } 325 | 326 | public function onRenderError($e) 327 | { 328 | return $this->getJsonModelError($e); 329 | } 330 | 331 | public function getJsonModelError($e) 332 | { 333 | $error = $e->getError(); 334 | if (!$error) { 335 | return; 336 | } 337 | 338 | $response = $e->getResponse(); 339 | $exception = $e->getParam('exception'); 340 | $exceptionJson = array(); 341 | if ($exception) { 342 | $exceptionJson = array( 343 | 'class' => get_class($exception), 344 | 'file' => $exception->getFile(), 345 | 'line' => $exception->getLine(), 346 | 'message' => $exception->getMessage(), 347 | 'stacktrace' => $exception->getTraceAsString() 348 | ); 349 | } 350 | 351 | $errorJson = array( 352 | 'message' => 'An error occurred during execution; please try again later.', 353 | 'error' => $error, 354 | 'exception' => $exceptionJson, 355 | ); 356 | if ($error == 'error-router-no-match') { 357 | $errorJson['message'] = 'Resource not found.'; 358 | } 359 | 360 | $model = new JsonModel(array('errors' => array($errorJson))); 361 | 362 | $e->setResult($model); 363 | 364 | return $model; 365 | } 366 | 367 | public function getConfig() 368 | { 369 | return include __DIR__ . '/config/module.config.php'; 370 | } 371 | 372 | public function getAutoloaderConfig() 373 | { 374 | return array( 375 | 'Zend\Loader\StandardAutoloader' => array( 376 | 'namespaces' => array( 377 | __NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__, 378 | ), 379 | ), 380 | ); 381 | } 382 | } 383 | ``` 384 | 385 | You can extend this approach to return error responses with specific [HTTP status codes](http://www.restapitutorial.com/httpstatuscodes.html) for exceptions by changing the response status code based on the exception (custom exceptions with appropiate codes/messages). 386 | 387 | ### Return response for unsupported method 388 | 389 | The [ZF2 AbstractRestfulController](https://github.com/zendframework/zf2/blob/master/library/Zend/Mvc/Controller/AbstractRestfulController.php) has base action functions for all standard HTTP methods, but if you don't want to support some for all resources it will try and return an array which is not a JsonModel so will cause a rendering exception. Correct this by creating and using your own AbstractRestfulJsonController which overrides these methods. 390 | 391 | AbstractRestfulJsonController.php 392 | 393 | ``` 394 | response->setStatusCode(405); 405 | throw new \Exception('Method Not Allowed'); 406 | } 407 | 408 | # Override default actions as they do not return valid JsonModels 409 | public function create($data) 410 | { 411 | return $this->methodNotAllowed(); 412 | } 413 | 414 | public function delete($id) 415 | { 416 | return $this->methodNotAllowed(); 417 | } 418 | 419 | public function deleteList() 420 | { 421 | return $this->methodNotAllowed(); 422 | } 423 | 424 | public function get($id) 425 | { 426 | return $this->methodNotAllowed(); 427 | } 428 | 429 | public function getList() 430 | { 431 | return $this->methodNotAllowed(); 432 | } 433 | 434 | public function head($id = null) 435 | { 436 | return $this->methodNotAllowed(); 437 | } 438 | 439 | public function options() 440 | { 441 | return $this->methodNotAllowed(); 442 | } 443 | 444 | public function patch($id, $data) 445 | { 446 | return $this->methodNotAllowed(); 447 | } 448 | 449 | public function replaceList($data) 450 | { 451 | return $this->methodNotAllowed(); 452 | } 453 | 454 | public function patchList($data) 455 | { 456 | return $this->methodNotAllowed(); 457 | } 458 | 459 | public function update($id, $data) 460 | { 461 | return $this->methodNotAllowed(); 462 | } 463 | } 464 | ``` 465 | 466 | ### get and getlist actions for child resources 467 | 468 | If you want to use child resources for resources, e.g. /album/2/track/10, you'll find a problem that the AbstractRestfulController will not route to getlist action functions correctly, instead it will go to get with the id for the parent resource . To correct this you need to add a separate controller and route for the list and item, and extract the child id param from the route directly. 469 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "php": ">=5.3.3", 4 | "zendframework/zendframework": ">=2.2.4" 5 | } 6 | } -------------------------------------------------------------------------------- /config/application.config.php: -------------------------------------------------------------------------------- 1 | array( 4 | 'AlbumApi', 5 | ), 6 | 'module_listener_options' => array( 7 | 'module_paths' => array( 8 | './module', 9 | './vendor', 10 | ), 11 | // local/global config location when needed 12 | //'config_glob_paths' => array( 13 | // 'config/autoload/{,*.}{global,local}.php', 14 | //), 15 | ), 16 | ); 17 | -------------------------------------------------------------------------------- /init_autoloader.php: -------------------------------------------------------------------------------- 1 | add('Zend', $zf2Path); 6 | 7 | if (!class_exists('Zend\Loader\AutoloaderFactory')) { 8 | throw new RuntimeException('Unable to load ZF2. Run `php composer.phar install` or define a ZF2_PATH environment variable.'); 9 | } 10 | -------------------------------------------------------------------------------- /module/AlbumApi/Module.php: -------------------------------------------------------------------------------- 1 | getApplication()->getEventManager(); 14 | $moduleRouteListener = new ModuleRouteListener(); 15 | $moduleRouteListener->attach($eventManager); 16 | 17 | $eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, array($this, 'onDispatchError'), 0); 18 | $eventManager->attach(MvcEvent::EVENT_RENDER_ERROR, array($this, 'onRenderError'), 0); 19 | } 20 | 21 | public function onDispatchError($e) 22 | { 23 | return $this->getJsonModelError($e); 24 | } 25 | 26 | public function onRenderError($e) 27 | { 28 | return $this->getJsonModelError($e); 29 | } 30 | 31 | public function getJsonModelError($e) 32 | { 33 | $error = $e->getError(); 34 | if (!$error) { 35 | return; 36 | } 37 | 38 | $response = $e->getResponse(); 39 | $exception = $e->getParam('exception'); 40 | $exceptionJson = array(); 41 | if ($exception) { 42 | $exceptionJson = array( 43 | 'class' => get_class($exception), 44 | 'file' => $exception->getFile(), 45 | 'line' => $exception->getLine(), 46 | 'message' => $exception->getMessage(), 47 | 'stacktrace' => $exception->getTraceAsString() 48 | ); 49 | } 50 | 51 | $errorJson = array( 52 | 'message' => 'An error occurred during execution; please try again later.', 53 | 'error' => $error, 54 | 'exception' => $exceptionJson, 55 | ); 56 | if ($error == 'error-router-no-match') { 57 | $errorJson['message'] = 'Resource not found.'; 58 | } 59 | 60 | $model = new JsonModel(array('errors' => array($errorJson))); 61 | 62 | $e->setResult($model); 63 | 64 | return $model; 65 | } 66 | 67 | public function getConfig() 68 | { 69 | return include __DIR__ . '/config/module.config.php'; 70 | } 71 | 72 | public function getAutoloaderConfig() 73 | { 74 | return array( 75 | 'Zend\Loader\StandardAutoloader' => array( 76 | 'namespaces' => array( 77 | __NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__, 78 | ), 79 | ), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /module/AlbumApi/config/module.config.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'routes' => array( 6 | 'home' => array( 7 | 'type' => 'Zend\Mvc\Router\Http\Literal', 8 | 'options' => array( 9 | 'route' => '/', 10 | 'defaults' => array( 11 | 'controller' => 'AlbumApi\Controller\Index', 12 | ), 13 | ), 14 | ), 15 | 'album' => array( 16 | 'type' => 'segment', 17 | 'options' => array( 18 | 'route' => '/album[/:id]', 19 | 'constraints' => array( 20 | 'id' => '[0-9]+', 21 | ), 22 | 'defaults' => array( 23 | 'controller' => 'AlbumApi\Controller\Album', 24 | ), 25 | ), 26 | ), 27 | ), 28 | ), 29 | 'controllers' => array( 30 | 'invokables' => array( 31 | 'AlbumApi\Controller\Index' => 'AlbumApi\Controller\IndexController', 32 | 'AlbumApi\Controller\Album' => 'AlbumApi\Controller\AlbumController', 33 | ), 34 | ), 35 | 'view_manager' => array( 36 | 'strategies' => array( 37 | 'ViewJsonStrategy', 38 | ), 39 | ), 40 | ); 41 | -------------------------------------------------------------------------------- /module/AlbumApi/src/AlbumApi/Controller/AbstractRestfulJsonController.php: -------------------------------------------------------------------------------- 1 | response->setStatusCode(405); 12 | throw new \Exception('Method Not Allowed'); 13 | } 14 | 15 | # Override default actions as they do not return valid JsonModels 16 | public function create($data) 17 | { 18 | return $this->methodNotAllowed(); 19 | } 20 | 21 | public function delete($id) 22 | { 23 | return $this->methodNotAllowed(); 24 | } 25 | 26 | public function deleteList() 27 | { 28 | return $this->methodNotAllowed(); 29 | } 30 | 31 | public function get($id) 32 | { 33 | return $this->methodNotAllowed(); 34 | } 35 | 36 | public function getList() 37 | { 38 | return $this->methodNotAllowed(); 39 | } 40 | 41 | public function head($id = null) 42 | { 43 | return $this->methodNotAllowed(); 44 | } 45 | 46 | public function options() 47 | { 48 | return $this->methodNotAllowed(); 49 | } 50 | 51 | public function patch($id, $data) 52 | { 53 | return $this->methodNotAllowed(); 54 | } 55 | 56 | public function replaceList($data) 57 | { 58 | return $this->methodNotAllowed(); 59 | } 60 | 61 | public function patchList($data) 62 | { 63 | return $this->methodNotAllowed(); 64 | } 65 | 66 | public function update($id, $data) 67 | { 68 | return $this->methodNotAllowed(); 69 | } 70 | } -------------------------------------------------------------------------------- /module/AlbumApi/src/AlbumApi/Controller/AlbumController.php: -------------------------------------------------------------------------------- 1 | 13 | array( 14 | array('id'=> 1, 'name' => 'Mothership', 'band' => 'Led Zeppelin'), 15 | array('id'=> 2, 'name' => 'Coda', 'band' => 'Led Zeppelin'), 16 | ) 17 | ) 18 | ); 19 | } 20 | 21 | public function get($id) 22 | { // Action used for GET requests with resource Id 23 | return new JsonModel(array("data" => array('id'=> 2, 'name' => 'Coda', 'band' => 'Led Zeppelin'))); 24 | } 25 | 26 | public function create($data) 27 | { // Action used for POST requests 28 | return new JsonModel(array('data' => array('id'=> 3, 'name' => 'New Album', 'band' => 'New Band'))); 29 | } 30 | 31 | public function update($id, $data) 32 | { // Action used for PUT requests 33 | return new JsonModel(array('data' => array('id'=> 3, 'name' => 'Updated Album', 'band' => 'Updated Band'))); 34 | } 35 | 36 | public function delete($id) 37 | { // Action used for DELETE requests 38 | return new JsonModel(array('data' => 'album id 3 deleted')); 39 | } 40 | } -------------------------------------------------------------------------------- /module/AlbumApi/src/AlbumApi/Controller/IndexController.php: -------------------------------------------------------------------------------- 1 | "Welcome to the Zend Framework Album API example")); 12 | } 13 | } -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | # The following rule tells Apache that if the requested filename 3 | # exists, simply serve it. 4 | RewriteCond %{REQUEST_FILENAME} -s [OR] 5 | RewriteCond %{REQUEST_FILENAME} -l [OR] 6 | RewriteCond %{REQUEST_FILENAME} -d 7 | RewriteRule ^.*$ - [NC,L] 8 | # The following rewrites all other queries to index.php. The 9 | # condition ensures that if you are using Apache aliases to do 10 | # mass virtual hosting, the base path will be prepended to 11 | # allow proper resolution of the index.php file; it will work 12 | # in non-aliased environments as well, providing a safe, one-size 13 | # fits all solution. 14 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$ 15 | RewriteRule ^(.*) - [E=BASE:%1] 16 | RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L] 17 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); --------------------------------------------------------------------------------