├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── TODO.md ├── composer.json ├── config └── module.config.php └── src ├── AbstractResourceListener.php ├── Exception ├── CreationException.php ├── ExceptionInterface.php ├── InvalidArgumentException.php ├── PatchException.php ├── RuntimeException.php └── UpdateException.php ├── Factory ├── OptionsListenerFactory.php └── RestControllerFactory.php ├── Listener ├── OptionsListener.php └── RestParametersListener.php ├── Module.php ├── Resource.php ├── ResourceEvent.php ├── ResourceInterface.php └── RestController.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file, in reverse chronological order by release. 4 | 5 | ## 1.5.1 - TBD 6 | 7 | ### Added 8 | 9 | - Nothing. 10 | 11 | ### Changed 12 | 13 | - Nothing. 14 | 15 | ### Deprecated 16 | 17 | - Nothing. 18 | 19 | ### Removed 20 | 21 | - Nothing. 22 | 23 | ### Fixed 24 | 25 | - Nothing. 26 | 27 | ## 1.5.0 - 2018-07-31 28 | 29 | ### Added 30 | 31 | - Nothing. 32 | 33 | ### Changed 34 | 35 | - [#115](https://github.com/zfcampus/zf-rest/pull/115) modifies how the query whitelist is generated. If an input filter exists for a `GET` request, 36 | the input names will be merged with the whitelist. 37 | 38 | ### Deprecated 39 | 40 | - Nothing. 41 | 42 | ### Removed 43 | 44 | - Nothing. 45 | 46 | ### Fixed 47 | 48 | - Nothing. 49 | 50 | ## 1.4.0 - 2018-05-02 51 | 52 | ### Added 53 | 54 | - [#107](https://github.com/zfcampus/zf-rest/pull/107) adds support for PHP 7.2. 55 | 56 | ### Changed 57 | 58 | - Nothing. 59 | 60 | ### Deprecated 61 | 62 | - Nothing. 63 | 64 | ### Removed 65 | 66 | - [#107](https://github.com/zfcampus/zf-rest/pull/107) removes support for HHVM. 67 | 68 | ### Fixed 69 | 70 | - Nothing. 71 | 72 | ## 1.3.3 - 2016-10-11 73 | 74 | ### Added 75 | 76 | - Nothing. 77 | 78 | ### Deprecated 79 | 80 | - Nothing. 81 | 82 | ### Removed 83 | 84 | - Nothing. 85 | 86 | ### Fixed 87 | 88 | - Updates the `composer.json` to have a minimum supported zf-api-problem version 89 | of 1.2.2; this is necessary for the fixes in #103 and #105 to work correctly. 90 | 91 | ## 1.3.2 - 2016-10-11 92 | 93 | ### Added 94 | 95 | - Nothing. 96 | 97 | ### Deprecated 98 | 99 | - Nothing. 100 | 101 | ### Removed 102 | 103 | - Nothing. 104 | 105 | ### Fixed 106 | 107 | - [#103](https://github.com/zfcampus/zf-rest/pull/103) and 108 | [#105](https://github.com/zfcampus/zf-rest/pull/105) fix an issue with 109 | providing a `Throwable` in order to create an `ApiProblem` from within a 110 | `RestController`. 111 | 112 | ## 1.3.1 - 2016-07-12 113 | 114 | ### Added 115 | 116 | - [#100](https://github.com/zfcampus/zf-rest/pull/100) adds configuration to the 117 | `composer.json` to allow zend-component-installer to auto-inject the 118 | `ZF\Rest` module into application configuration during installation. 119 | 120 | ### Deprecated 121 | 122 | - Nothing. 123 | 124 | ### Removed 125 | 126 | - Nothing. 127 | 128 | ### Fixed 129 | 130 | - Nothing. 131 | 132 | ## 1.3.0 - 2016-07-12 133 | 134 | ### Added 135 | 136 | - [#99](https://github.com/zfcampus/zf-rest/pull/99) adds support for v3 137 | releases of Zend Framework components, while retaining compatibility for v2 138 | releases. 139 | - [#96](https://github.com/zfcampus/zf-rest/pull/96) adds a `Content-Location` 140 | header to responses returned from `RestController::create()`, per 141 | [RFC 7231](https://tools.ietf.org/html/rfc7231#section-3.1.4.2). 142 | 143 | ### Deprecated 144 | 145 | - Nothing. 146 | 147 | ### Removed 148 | 149 | - [#99](https://github.com/zfcampus/zf-rest/pull/99) removes support for PHP 5.5. 150 | 151 | ### Fixed 152 | 153 | - [#70](https://github.com/zfcampus/zf-rest/pull/70) updates how the 154 | `RestController` retrieves the identifier from `ZF\Hal\Entity` instances to 155 | use the new `getId()` method introduced in zf-hal 1.4. 156 | - [#94](https://github.com/zfcampus/zf-rest/pull/94) updates the 157 | `RestController` to return Problem Details with a status of 400 if the 158 | page size requested by the client is below zero. 159 | 160 | ## 1.2.1 - 2016-07-12 161 | 162 | ### Added 163 | 164 | - Nothing. 165 | 166 | ### Deprecated 167 | 168 | - Nothing. 169 | 170 | ### Removed 171 | 172 | - Nothing. 173 | 174 | ### Fixed 175 | 176 | - [#97](https://github.com/zfcampus/zf-rest/pull/97) fixes `Location` header 177 | generation in the `RestController::create()` method to only use the `href` 178 | property of the relational link; previously, if you'd defined additional 179 | properties, these were also incorrectly serialized in the generated link. 180 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2018, Zend Technologies USA, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | - Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | - Neither the name of Zend Technologies USA, Inc. nor the names of its 15 | contributors may be used to endorse or promote products derived from this 16 | software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ZF REST 2 | ======= 3 | 4 | > ## Repository abandoned 2019-12-31 5 | > 6 | > This repository has moved to [laminas-api-tools/api-tools-rest](https://github.com/laminas-api-tools/api-tools-rest). 7 | 8 | [![Build Status](https://secure.travis-ci.org/zfcampus/zf-rest.svg?branch=master)](https://secure.travis-ci.org/zfcampus/zf-rest) 9 | [![Coverage Status](https://coveralls.io/repos/github/zfcampus/zf-rest/badge.svg?branch=master)](https://coveralls.io/github/zfcampus/zf-rest?branch=master) 10 | 11 | Introduction 12 | ------------ 13 | 14 | This module provides structure and code for quickly implementing RESTful APIs 15 | that use JSON as a transport. 16 | 17 | It allows you to create RESTful JSON APIs that use the following standards: 18 | 19 | - [Hypermedia Application Language](http://tools.ietf.org/html/draft-kelly-json-hal-06), aka HAL, 20 | used for creating JSON payloads with hypermedia controls. 21 | - [Problem Details for HTTP APIs](http://tools.ietf.org/html/draft-nottingham-http-problem-06), 22 | aka API Problem, used for reporting API problems. 23 | 24 | Requirements 25 | ------------ 26 | 27 | Please see the [composer.json](composer.json) file. 28 | 29 | Installation 30 | ------------ 31 | 32 | Run the following `composer` command: 33 | 34 | ```console 35 | $ composer require zfcampus/zf-rest 36 | ``` 37 | 38 | Alternately, manually add the following to your `composer.json`, in the `require` section: 39 | 40 | ```javascript 41 | "require": { 42 | "zfcampus/zf-rest": "^1.3" 43 | } 44 | ``` 45 | 46 | And then run `composer update` to ensure the module is installed. 47 | 48 | Finally, add the module name to your project's `config/application.config.php` under the `modules` 49 | key: 50 | 51 | ```php 52 | return [ 53 | /* ... */ 54 | 'modules' => [ 55 | /* ... */ 56 | 'ZF\Rest', 57 | ], 58 | /* ... */ 59 | ]; 60 | ``` 61 | 62 | > ### zf-component-installer 63 | > 64 | > If you use [zf-component-installer](https://github.com/zendframework/zf-component-installer), 65 | > that plugin will install zf-rest as a module for you. 66 | 67 | Configuration 68 | ============= 69 | 70 | ### User Configuration 71 | 72 | The top-level key used to configure this module is `zf-rest`. 73 | 74 | #### Key: Controller Service Name 75 | 76 | Each key under `zf-rest` is a controller service name, and the value is an array with one or more of 77 | the following keys. 78 | 79 | ##### Sub-key: `collection_http_methods` 80 | 81 | An array of HTTP methods that are allowed when making requests to a collection. 82 | 83 | ##### Sub-key: `entity_http_methods` 84 | 85 | An array of HTTP methods that are allowed when making requests for entities. 86 | 87 | ##### Sub-key: `collection_name` 88 | 89 | The name of the embedded property in the representation denoting the collection. 90 | 91 | ##### Sub-key: `collection_query_whitelist` (optional) 92 | 93 | An array of query string arguments to whitelist for collection requests and when generating links 94 | to collections. These parameters will be passed to the resource class' `fetchAll()` method. Any of 95 | these parameters present in the request will also be used when generating links to the collection. 96 | 97 | Examples of query string arguments you may want to whitelist include "sort", "filter", etc. 98 | 99 | **Starting in 1.5.0**: if a input filter exists for the `GET` HTTP method, its 100 | keys will be merged with those from configuration. 101 | 102 | ##### Sub-key: `controller_class` (optional) 103 | 104 | An alternate controller class to use when creating the controller service; it **must** extend 105 | `ZF\Rest\RestController`. Only use this if you are altering the workflow present in the 106 | `RestController`. 107 | 108 | ##### Sub-key: `identifier` (optional) 109 | 110 | The name of event identifier for controller. It allows multiple instances of controller to react 111 | to different sets of shared events. 112 | 113 | ##### Sub-key: `resource_identifiers` (optional) 114 | 115 | The name or an array of names of event identifier/s for resource. 116 | 117 | ##### Sub-key: `entity_class` 118 | 119 | The class to be used for representing an entity. Primarily useful for introspection (for example in 120 | the Apigility Admin UI). 121 | 122 | ##### Sub-key: `route_name` 123 | 124 | The route name associated with this REST service. This is utilized when links need to be generated 125 | in the response. 126 | 127 | ##### Sub-key: `route_identifier_name` 128 | 129 | The parameter name for the identifier in the route specification. 130 | 131 | ##### Sub-key: `listener` 132 | 133 | The resource class that will be dispatched to handle any collection or entity requests. 134 | 135 | ##### Sub-key: `page_size` 136 | 137 | The number of entities to return per "page" of a collection. This is only used if the collection 138 | returned is a `Zend\Paginator\Paginator` instance or derivative. 139 | 140 | ##### Sub-key: `max_page_size` (optional) 141 | 142 | The maximum number of entities to return per "page" of a collection. This is tested against the 143 | `page_size_param`. This parameter can be set to help prevent denial of service attacks against your API. 144 | 145 | ##### Sub-key: `min_page_size` (optional) 146 | 147 | The minimum number of entities to return per "page" of a collection. This is tested against the 148 | `page_size_param`. 149 | 150 | ##### Sub-key: `page_size_param` (optional) 151 | 152 | The name of a query string argument that will set a per-request page size. Not set by default; we 153 | recommend having additional logic to ensure a ceiling for the page size as well, to prevent denial 154 | of service attacks on your API. 155 | 156 | #### User configuration example: 157 | 158 | ```php 159 | 'AddressBook\\V1\\Rest\\Contact\\Controller' => [ 160 | 'listener' => 'AddressBook\\V1\\Rest\\Contact\\ContactResource', 161 | 'route_name' => 'address-book.rest.contact', 162 | 'route_identifier_name' => 'contact_id', 163 | 'collection_name' => 'contact', 164 | 'entity_http_methods' => [ 165 | 0 => 'GET', 166 | 1 => 'PATCH', 167 | 2 => 'PUT', 168 | 3 => 'DELETE', 169 | ], 170 | 'collection_http_methods' => [ 171 | 0 => 'GET', 172 | 1 => 'POST', 173 | ], 174 | 'collection_query_whitelist' => [], 175 | 'page_size' => 25, 176 | 'page_size_param' => null, 177 | 'entity_class' => 'AddressBook\\V1\\Rest\\Contact\\ContactEntity', 178 | 'collection_class' => 'AddressBook\\V1\\Rest\\Contact\\ContactCollection', 179 | 'service_name' => 'Contact', 180 | ], 181 | ``` 182 | 183 | ### System Configuration 184 | 185 | The `zf-rest` module provides the following configuration to ensure it operates properly in a Zend 186 | Framework application. 187 | 188 | ```php 189 | 'service_manager' => [ 190 | 'invokables' => [ 191 | 'ZF\Rest\RestParametersListener' => 'ZF\Rest\Listener\RestParametersListener', 192 | ], 193 | 'factories' => [ 194 | 'ZF\Rest\OptionsListener' => 'ZF\Rest\Factory\OptionsListenerFactory', 195 | ], 196 | ], 197 | 198 | 'controllers' => [ 199 | 'abstract_factories' => [ 200 | 'ZF\Rest\Factory\RestControllerFactory', 201 | ], 202 | ], 203 | 204 | 'view_manager' => [ 205 | // Enable this in your application configuration in order to get full 206 | // exception stack traces in your API-Problem responses. 207 | 'display_exceptions' => false, 208 | ], 209 | ``` 210 | 211 | ZF2 Events 212 | ========== 213 | 214 | ### Listeners 215 | 216 | #### ZF\Rest\Listener\OptionsListener 217 | 218 | This listener is registered to the `MvcEvent::EVENT_ROUTE` event with a priority of `-100`. 219 | It serves two purposes: 220 | 221 | - If a request is made to either a REST entity or collection with a method they do not support, it 222 | will return a `405 Method not allowed` response, with a populated `Allow` header indicating which 223 | request methods may be used. 224 | - For `OPTIONS` requests, it will respond with a `200 OK` response and a populated `Allow` header 225 | indicating which request methods may be used. 226 | 227 | #### ZF\Rest\Listener\RestParametersListener 228 | 229 | This listener is attached to the shared `dispatch` event at priority `100`. The listener maps query 230 | string arguments from the request to the `Resource` object composed in the `RestController`, as well 231 | as injects the `RouteMatch`. 232 | 233 | ZF2 Services 234 | ============ 235 | 236 | ### Models 237 | 238 | #### ZF\Rest\AbstractResourceListener 239 | 240 | This abstract class is the base implementation of a [Resource](#zfrestresource) listener. Since 241 | dispatching of `zf-rest` based REST services is event driven, a listener must be constructed to 242 | listen for events triggered from `ZF\Rest\Resource` (which is called from the `RestController`). 243 | The following methods are called during `dispatch()`, depending on the HTTP method: 244 | 245 | - `create($data)` - Triggered by a `POST` request to a resource *collection*. 246 | - `delete($id)` - Triggered by a `DELETE` request to a resource *entity*. 247 | - `deleteList($data)` - Triggered by a `DELETE` request to a resource *collection*. 248 | - `fetch($id)` - Triggered by a `GET` request to a resource *entity*. 249 | - `fetchAll($params = [])` - Triggered by a `GET` request to a resource *collection*. 250 | - `patch($id, $data)` - Triggered by a `PATCH` request to resource *entity*. 251 | - `patchList($data)` - Triggered by a `PATCH` request to a resource *collection*. 252 | - `update($id, $data)` - Triggered by a `PUT` request to a resource *entity*. 253 | - `replaceList($data)` - Triggered by a `PUT` request to a resource *collection*. 254 | 255 | #### ZF\Rest\Resource 256 | 257 | The `Resource` object handles dispatching business logic for REST requests. It composes an 258 | `EventManager` instance in order to delegate operations to attached listeners. Additionally, it 259 | composes request information, such as the `Request`, `RouteMatch`, and `MvcEvent` objects, in order 260 | to seed the `ResourceEvent` it creates and passes to listeners when triggering events. 261 | 262 | ### Controller 263 | 264 | #### ZF\Rest\RestController 265 | 266 | This is the base controller implementation used when a controller service name matches a configured 267 | REST service. All REST services managed by `zf-rest` will use this controller (though separate 268 | instances of it), unless they specify a [controller_class](#subkeycontrollerclassoptional) option. 269 | Instances are created via the `ZF\Rest\Factory\RestControllerFactory` abstract factory. 270 | 271 | The `RestController` calls the appropriate method in `ZF\Rest\Resource` based on the requested HTTP 272 | method. It returns [HAL](https://github.com/zfcampus/zf-hal) payloads on success, and [API 273 | Problem](https://github.com/zfcampus/zf-api-problem) responses on error. 274 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zfcampus/zf-rest", 3 | "description": "ZF Module providing structure for RESTful resources", 4 | "license": "BSD-3-Clause", 5 | "keywords": [ 6 | "zendframework", 7 | "zf", 8 | "zf3", 9 | "zend", 10 | "module", 11 | "rest" 12 | ], 13 | "support": { 14 | "issues": "https://github.com/zfcampus/zf-rest/issues", 15 | "source": "https://github.com/zfcampus/zf-rest", 16 | "rss": "https://github.com/zfcampus/zf-rest/releases.atom", 17 | "chat": "https://zendframework-slack.herokuapp.com", 18 | "forum": "https://discourse.zendframework.com/c/questions/apigility" 19 | }, 20 | "require": { 21 | "php": "^5.6 || ^7.0", 22 | "zendframework/zend-eventmanager": "^2.6.3 || ^3.0.1", 23 | "zendframework/zend-mvc": "^2.7.14 || ^3.0.2", 24 | "zendframework/zend-paginator": "^2.7", 25 | "zendframework/zend-stdlib": "^2.7.7 || ^3.0.1", 26 | "zfcampus/zf-api-problem": "^1.2.2", 27 | "zfcampus/zf-content-negotiation": "^1.2.1", 28 | "zfcampus/zf-hal": "^1.4", 29 | "zfcampus/zf-mvc-auth": "^1.4" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.5", 33 | "zendframework/zend-coding-standard": "~1.0.0", 34 | "zendframework/zend-escaper": "^2.5.2", 35 | "zendframework/zend-http": "^2.5.4", 36 | "zendframework/zend-inputfilter": "^2.7.2", 37 | "zendframework/zend-servicemanager": "^2.7.6 || ^3.1", 38 | "zendframework/zend-uri": "^2.5.2", 39 | "zendframework/zend-validator": "^2.8.1", 40 | "zendframework/zend-view": "^2.8.1" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "ZF\\Rest\\": "src/" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "ZFTest\\Rest\\": "test/" 50 | } 51 | }, 52 | "config": { 53 | "sort-packages": true 54 | }, 55 | "extra": { 56 | "branch-alias": { 57 | "dev-master": "1.5.x-dev", 58 | "dev-develop": "1.6.x-dev" 59 | }, 60 | "zf": { 61 | "module": "ZF\\Rest" 62 | } 63 | }, 64 | "scripts": { 65 | "check": [ 66 | "@cs-check", 67 | "@test" 68 | ], 69 | "cs-check": "phpcs", 70 | "cs-fix": "phpcbf", 71 | "test": "phpunit --colors=always", 72 | "test-coverage": "phpunit --colors=always --coverage-clover clover.xml" 73 | }, 74 | "homepage": "http://apigility.org/" 75 | } 76 | -------------------------------------------------------------------------------- /config/module.config.php: -------------------------------------------------------------------------------- 1 | [ 9 | // @codingStandardsIgnoreStart 10 | // 'Name of virtual controller' => [ 11 | // 'collection_http_methods' => [ 12 | // /* array of HTTP methods that are allowed on collections */ 13 | // 'get' 14 | // ], 15 | // 'collection_name' => 'Name of property denoting collection in response', 16 | // 'collection_query_whitelist' => [ 17 | // /* array of query string parameters to whitelist and return 18 | // * when generating links to the collection. E.g., "sort", 19 | // * "filter", etc. 20 | // */ 21 | // ], 22 | // 'controller_class' => 'Name of ZF\Rest\RestController derivative, if not using that class', 23 | // 'route_identifier_name' => 'Name of parameter in route that acts as an entity identifier', 24 | // 'listener' => 'Name of service/class that acts as a listener on the composed Resource', 25 | // 'page_size' => 'Integer specifying the number of results to return per page, if collections are paginated', 26 | // 'page_size_param' => 'Name of query string parameter that specifies the number of results to return per page', 27 | // 'entity_http_methods' => [ 28 | // /* array of HTTP methods that are allowed on individual entities */ 29 | // 'get', 'post', 'delete' 30 | // ], 31 | // 'route_name' => 'Name of the route that will map to this controller', 32 | // ], 33 | // repeat for each controller you want to define 34 | // @codingStandardsIgnoreEnd 35 | ], 36 | 37 | 'service_manager' => [ 38 | 'invokables' => [ 39 | 'ZF\Rest\RestParametersListener' => 'ZF\Rest\Listener\RestParametersListener', 40 | ], 41 | 'factories' => [ 42 | 'ZF\Rest\OptionsListener' => 'ZF\Rest\Factory\OptionsListenerFactory', 43 | ], 44 | ], 45 | 46 | 'controllers' => [ 47 | 'abstract_factories' => [ 48 | 'ZF\Rest\Factory\RestControllerFactory', 49 | ], 50 | ], 51 | 52 | 'view_manager' => [ 53 | // Enable this in your application configuration in order to get full 54 | // exception stack traces in your API-Problem responses. 55 | 'display_exceptions' => false, 56 | ], 57 | ]; 58 | -------------------------------------------------------------------------------- /src/AbstractResourceListener.php: -------------------------------------------------------------------------------- 1 | entityClass = $className; 53 | return $this; 54 | } 55 | 56 | public function getEntityClass() 57 | { 58 | return $this->entityClass; 59 | } 60 | 61 | public function setCollectionClass($className) 62 | { 63 | $this->collectionClass = $className; 64 | return $this; 65 | } 66 | 67 | public function getCollectionClass() 68 | { 69 | return $this->collectionClass; 70 | } 71 | 72 | /** 73 | * Retrieve the current resource event, if any 74 | * 75 | * @return ResourceEvent 76 | */ 77 | public function getEvent() 78 | { 79 | return $this->event; 80 | } 81 | 82 | /** 83 | * Retrieve the identity, if any 84 | * 85 | * Proxies to the resource event to find the identity, if not already 86 | * composed, and composes it. 87 | * 88 | * @return null|\ZF\MvcAuth\Identity\IdentityInterface 89 | */ 90 | public function getIdentity() 91 | { 92 | if ($this->identity) { 93 | return $this->identity; 94 | } 95 | 96 | $event = $this->getEvent(); 97 | if (! $event instanceof ResourceEvent) { 98 | return null; 99 | } 100 | 101 | $this->identity = $event->getIdentity(); 102 | return $this->identity; 103 | } 104 | 105 | /** 106 | * Retrieve the input filter, if any 107 | * 108 | * Proxies to the resource event to find the input filter, if not already 109 | * composed, and composes it. 110 | * 111 | * @return null|\Zend\InputFilter\InputFilterInterface 112 | */ 113 | public function getInputFilter() 114 | { 115 | if ($this->inputFilter) { 116 | return $this->inputFilter; 117 | } 118 | 119 | $event = $this->getEvent(); 120 | if (! $event instanceof ResourceEvent) { 121 | return null; 122 | } 123 | 124 | $this->inputFilter = $event->getInputFilter(); 125 | return $this->inputFilter; 126 | } 127 | 128 | /** 129 | * Attach listeners for all Resource events 130 | * 131 | * @param EventManagerInterface $events 132 | */ 133 | public function attach(EventManagerInterface $events, $priority = 1) 134 | { 135 | $this->listeners[] = $events->attach('create', [$this, 'dispatch']); 136 | $this->listeners[] = $events->attach('delete', [$this, 'dispatch']); 137 | $this->listeners[] = $events->attach('deleteList', [$this, 'dispatch']); 138 | $this->listeners[] = $events->attach('fetch', [$this, 'dispatch']); 139 | $this->listeners[] = $events->attach('fetchAll', [$this, 'dispatch']); 140 | $this->listeners[] = $events->attach('patch', [$this, 'dispatch']); 141 | $this->listeners[] = $events->attach('patchList', [$this, 'dispatch']); 142 | $this->listeners[] = $events->attach('replaceList', [$this, 'dispatch']); 143 | $this->listeners[] = $events->attach('update', [$this, 'dispatch']); 144 | } 145 | 146 | /** 147 | * Dispatch an incoming event to the appropriate method 148 | * 149 | * Marshals arguments from the event parameters. 150 | * 151 | * @param ResourceEvent $event 152 | * @return mixed 153 | */ 154 | public function dispatch(ResourceEvent $event) 155 | { 156 | $this->event = $event; 157 | switch ($event->getName()) { 158 | case 'create': 159 | $data = $event->getParam('data', []); 160 | return $this->create($data); 161 | case 'delete': 162 | $id = $event->getParam('id', null); 163 | return $this->delete($id); 164 | case 'deleteList': 165 | $data = $event->getParam('data', []); 166 | return $this->deleteList($data); 167 | case 'fetch': 168 | $id = $event->getParam('id', null); 169 | return $this->fetch($id); 170 | case 'fetchAll': 171 | $queryParams = $event->getQueryParams() ?: []; 172 | return $this->fetchAll($queryParams); 173 | case 'patch': 174 | $id = $event->getParam('id', null); 175 | $data = $event->getParam('data', []); 176 | return $this->patch($id, $data); 177 | case 'patchList': 178 | $data = $event->getParam('data', []); 179 | return $this->patchList($data); 180 | case 'replaceList': 181 | $data = $event->getParam('data', []); 182 | return $this->replaceList($data); 183 | case 'update': 184 | $id = $event->getParam('id', null); 185 | $data = $event->getParam('data', []); 186 | return $this->update($id, $data); 187 | default: 188 | throw new Exception\RuntimeException(sprintf( 189 | '%s has not been setup to handle the event "%s"', 190 | __METHOD__, 191 | $event->getName() 192 | )); 193 | } 194 | } 195 | 196 | /** 197 | * Create a resource 198 | * 199 | * @param mixed $data 200 | * @return ApiProblem|mixed 201 | */ 202 | public function create($data) 203 | { 204 | return new ApiProblem(405, 'The POST method has not been defined'); 205 | } 206 | 207 | /** 208 | * Delete a resource 209 | * 210 | * @param mixed $id 211 | * @return ApiProblem|mixed 212 | */ 213 | public function delete($id) 214 | { 215 | return new ApiProblem(405, 'The DELETE method has not been defined for individual resources'); 216 | } 217 | 218 | /** 219 | * Delete a collection, or members of a collection 220 | * 221 | * @param mixed $data 222 | * @return ApiProblem|mixed 223 | */ 224 | public function deleteList($data) 225 | { 226 | return new ApiProblem(405, 'The DELETE method has not been defined for collections'); 227 | } 228 | 229 | /** 230 | * Fetch a resource 231 | * 232 | * @param mixed $id 233 | * @return ApiProblem|mixed 234 | */ 235 | public function fetch($id) 236 | { 237 | return new ApiProblem(405, 'The GET method has not been defined for individual resources'); 238 | } 239 | 240 | /** 241 | * Fetch all or a subset of resources 242 | * 243 | * @param array $params 244 | * @return ApiProblem|mixed 245 | */ 246 | public function fetchAll($params = []) 247 | { 248 | return new ApiProblem(405, 'The GET method has not been defined for collections'); 249 | } 250 | 251 | /** 252 | * Patch (partial in-place update) a resource 253 | * 254 | * @param mixed $id 255 | * @param mixed $data 256 | * @return ApiProblem|mixed 257 | */ 258 | public function patch($id, $data) 259 | { 260 | return new ApiProblem(405, 'The PATCH method has not been defined for individual resources'); 261 | } 262 | 263 | /** 264 | * Patch (partial in-place update) a collection or members of a collection 265 | * 266 | * @param mixed $data 267 | * @return ApiProblem|mixed 268 | */ 269 | public function patchList($data) 270 | { 271 | return new ApiProblem(405, 'The PATCH method has not been defined for collections'); 272 | } 273 | 274 | /** 275 | * Replace a collection or members of a collection 276 | * 277 | * @param mixed $data 278 | * @return ApiProblem|mixed 279 | */ 280 | public function replaceList($data) 281 | { 282 | return new ApiProblem(405, 'The PUT method has not been defined for collections'); 283 | } 284 | 285 | /** 286 | * Update a resource 287 | * 288 | * @param mixed $id 289 | * @param mixed $data 290 | * @return ApiProblem|mixed 291 | */ 292 | public function update($id, $data) 293 | { 294 | return new ApiProblem(405, 'The PUT method has not been defined for individual resources'); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/Exception/CreationException.php: -------------------------------------------------------------------------------- 1 | getConfig($container)); 27 | } 28 | 29 | /** 30 | * Create and return an OptionsListener instance (v2). 31 | * 32 | * Provided for backwards compatibility; proxies to __invoke(). 33 | * 34 | * @param ServiceLocatorInterface $container 35 | * @return OptionsListener 36 | */ 37 | public function createService(ServiceLocatorInterface $container) 38 | { 39 | return $this($container, OptionsListener::class); 40 | } 41 | 42 | /** 43 | * Retrieve zf-rest config from the container, if available. 44 | * 45 | * @param ContainerInterface $container 46 | * @return array 47 | */ 48 | private function getConfig(ContainerInterface $container) 49 | { 50 | if (! $container->has('config')) { 51 | return []; 52 | } 53 | 54 | $config = $container->get('config'); 55 | 56 | if (! array_key_exists('zf-rest', $config) 57 | || ! is_array($config['zf-rest']) 58 | ) { 59 | return []; 60 | } 61 | 62 | return $config['zf-rest']; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Factory/RestControllerFactory.php: -------------------------------------------------------------------------------- 1 | lookupCache)) { 44 | return $this->lookupCache[$requestedName]; 45 | } 46 | 47 | if (! $container->has('config') || ! $container->has('EventManager')) { 48 | // Config and EventManager are required 49 | return false; 50 | } 51 | 52 | $config = $container->get('config'); 53 | if (! isset($config['zf-rest']) 54 | || ! is_array($config['zf-rest']) 55 | ) { 56 | $this->lookupCache[$requestedName] = false; 57 | return false; 58 | } 59 | $config = $config['zf-rest']; 60 | 61 | if (! isset($config[$requestedName]) 62 | || ! isset($config[$requestedName]['listener']) 63 | || ! isset($config[$requestedName]['route_name']) 64 | ) { 65 | // Configuration, and specifically the listener and route_name 66 | // keys, is required 67 | $this->lookupCache[$requestedName] = false; 68 | return false; 69 | } 70 | 71 | if (! $container->has($config[$requestedName]['listener']) 72 | && ! class_exists($config[$requestedName]['listener']) 73 | ) { 74 | // Service referenced by listener key is required 75 | $this->lookupCache[$requestedName] = false; 76 | throw new ServiceNotFoundException(sprintf( 77 | '%s requires that a valid "listener" service be specified for controller %s; no service found', 78 | __METHOD__, 79 | $requestedName 80 | )); 81 | } 82 | 83 | $this->lookupCache[$requestedName] = true; 84 | return true; 85 | } 86 | 87 | /** 88 | * Determine if we can create a service with name (v2). 89 | * 90 | * Provided for backwards compatibility; proxies to canCreate(). 91 | * 92 | * @param ServiceLocatorInterface $controllers 93 | * @param string $name 94 | * @param string $requestedName 95 | * @return bool 96 | */ 97 | public function canCreateServiceWithName(ServiceLocatorInterface $controllers, $name, $requestedName) 98 | { 99 | $container = $controllers->getServiceLocator() ?: $controllers; 100 | return $this->canCreate($container, $requestedName); 101 | } 102 | 103 | /** 104 | * Create named controller instance 105 | * 106 | * @param ContainerInterface $container 107 | * @param string $requestedName 108 | * @param null|array $options 109 | * @return RestController 110 | * @throws ServiceNotCreatedException if listener specified is not a ListenerAggregate 111 | */ 112 | public function __invoke(ContainerInterface $container, $requestedName, array $options = null) 113 | { 114 | $config = $container->get('config'); 115 | $config = $config['zf-rest'][$requestedName]; 116 | 117 | if ($container->has($config['listener'])) { 118 | $listener = $container->get($config['listener']); 119 | } else { 120 | $listener = new $config['listener']; 121 | } 122 | 123 | if (! $listener instanceof ListenerAggregateInterface) { 124 | throw new ServiceNotCreatedException(sprintf( 125 | '%s expects that the "listener" reference a service that implements ' 126 | . 'Zend\EventManager\ListenerAggregateInterface; received %s', 127 | __METHOD__, 128 | (is_object($listener) ? get_class($listener) : gettype($listener)) 129 | )); 130 | } 131 | 132 | $resourceIdentifiers = [get_class($listener)]; 133 | if (isset($config['resource_identifiers'])) { 134 | if (! is_array($config['resource_identifiers'])) { 135 | $config['resource_identifiers'] = (array) $config['resource_identifiers']; 136 | } 137 | $resourceIdentifiers = array_merge($resourceIdentifiers, $config['resource_identifiers']); 138 | } 139 | 140 | $events = $container->get('EventManager'); 141 | $events->setIdentifiers($resourceIdentifiers); 142 | $listener->attach($events); 143 | 144 | $resource = new Resource(); 145 | $resource->setEventManager($events); 146 | 147 | $identifier = $requestedName; 148 | if (isset($config['identifier'])) { 149 | $identifier = $config['identifier']; 150 | } 151 | 152 | $controllerClass = isset($config['controller_class']) ? $config['controller_class'] : 'ZF\Rest\RestController'; 153 | $controller = new $controllerClass($identifier); 154 | 155 | if (! $controller instanceof RestController) { 156 | throw new ServiceNotCreatedException(sprintf( 157 | '"%s" must be an implementation of ZF\Rest\RestController', 158 | $controllerClass 159 | )); 160 | } 161 | 162 | $controller->setEventManager($container->get('EventManager')); 163 | $controller->setResource($resource); 164 | $this->setControllerOptions($config, $controller); 165 | 166 | if (isset($config['entity_class'])) { 167 | $listener->setEntityClass($config['entity_class']); 168 | } 169 | 170 | if (isset($config['collection_class'])) { 171 | $listener->setCollectionClass($config['collection_class']); 172 | } 173 | 174 | return $controller; 175 | } 176 | 177 | /** 178 | * Create named controller instance (v2). 179 | * 180 | * Provided for backwards compatibility; proxies to __invoke(). 181 | * 182 | * @param ServiceLocatorInterface $controllers 183 | * @param string $name 184 | * @param string $requestedName 185 | * @return RestController 186 | * @throws ServiceNotCreatedException if listener specified is not a ListenerAggregate 187 | */ 188 | public function createServiceWithName(ServiceLocatorInterface $controllers, $name, $requestedName) 189 | { 190 | $container = $controllers->getServiceLocator() ?: $controllers; 191 | return $this($container, $requestedName); 192 | } 193 | 194 | /** 195 | * Loop through configuration to discover and set controller options. 196 | * 197 | * @param array $config 198 | * @param RestController $controller 199 | */ 200 | protected function setControllerOptions(array $config, RestController $controller) 201 | { 202 | foreach ($config as $option => $value) { 203 | switch ($option) { 204 | case 'collection_http_methods': 205 | $controller->setCollectionHttpMethods($value); 206 | break; 207 | 208 | case 'collection_name': 209 | $controller->setCollectionName($value); 210 | break; 211 | 212 | case 'collection_query_whitelist': 213 | if (is_string($value)) { 214 | $value = (array) $value; 215 | } 216 | if (! is_array($value)) { 217 | break; 218 | } 219 | 220 | // Create a listener that checks the query string against 221 | // the whitelisted query parameters in order to seed the 222 | // collection route options. 223 | $whitelist = $value; 224 | $controller->getEventManager()->attach('getList.pre', function (Event $e) use ($whitelist) { 225 | $controller = $e->getTarget(); 226 | $resource = $controller->getResource(); 227 | if (! $resource instanceof Resource) { 228 | // ResourceInterface does not define setQueryParams, so we need 229 | // specifically a Resource instance 230 | return; 231 | } 232 | 233 | $request = $controller->getRequest(); 234 | if (! method_exists($request, 'getQuery')) { 235 | return; 236 | } 237 | 238 | $query = $request->getQuery(); 239 | $params = new Parameters([]); 240 | 241 | // If a query Input Filter exists, merge its keys with the query whitelist 242 | if ($resource->getInputFilter()) { 243 | $whitelist = array_unique(array_merge( 244 | $whitelist, 245 | array_keys($resource->getInputFilter()->getInputs()) 246 | )); 247 | } 248 | foreach ($query as $key => $value) { 249 | if (! in_array($key, $whitelist)) { 250 | continue; 251 | } 252 | $params->set($key, $value); 253 | } 254 | $resource->setQueryParams($params); 255 | }); 256 | 257 | $controller->getEventManager()->attach('getList.post', function (Event $e) use ($whitelist) { 258 | $controller = $e->getTarget(); 259 | $resource = $controller->getResource(); 260 | if (! $resource instanceof Resource) { 261 | // ResourceInterface does not define setQueryParams, so we need 262 | // specifically a Resource instance 263 | return; 264 | } 265 | 266 | $collection = $e->getParam('collection'); 267 | if (! $collection instanceof Collection) { 268 | return; 269 | } 270 | 271 | $params = $resource->getQueryParams()->getArrayCopy(); 272 | 273 | // Set collection route options with the captured query whitelist, to 274 | // ensure paginated links are generated correctly 275 | $collection->setCollectionRouteOptions([ 276 | 'query' => $params, 277 | ]); 278 | 279 | // If no self link defined, set the options in the collection and return 280 | $links = $collection->getLinks(); 281 | if (! $links->has('self')) { 282 | return; 283 | } 284 | 285 | // If self link is defined, but is not route-based, return 286 | $self = $links->get('self'); 287 | if (! $self->hasRoute()) { 288 | return; 289 | } 290 | 291 | // Otherwise, merge the query string parameters with 292 | // the self link's route options 293 | $self = $links->get('self'); 294 | $options = $self->getRouteOptions(); 295 | $self->setRouteOptions(array_merge($options, [ 296 | 'query' => $params, 297 | ])); 298 | }); 299 | break; 300 | 301 | case 'entity_http_methods': 302 | $controller->setEntityHttpMethods($value); 303 | break; 304 | 305 | /** 306 | * The identifierName is a property of the ancestor 307 | * and is described by Apigility as route_identifier_name 308 | */ 309 | case 'route_identifier_name': 310 | $controller->setIdentifierName($value); 311 | break; 312 | 313 | case 'min_page_size': 314 | $controller->setMinPageSize($value); 315 | break; 316 | 317 | case 'page_size': 318 | $controller->setPageSize($value); 319 | break; 320 | 321 | case 'max_page_size': 322 | $controller->setMaxPageSize($value); 323 | break; 324 | 325 | case 'page_size_param': 326 | $controller->setPageSizeParam($value); 327 | break; 328 | 329 | /** 330 | * @todo Remove this by 1.0; BC only, starting in 0.9.0 331 | */ 332 | case 'resource_http_methods': 333 | $controller->setEntityHttpMethods($value); 334 | break; 335 | 336 | case 'route_name': 337 | $controller->setRoute($value); 338 | break; 339 | } 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/Listener/OptionsListener.php: -------------------------------------------------------------------------------- 1 | config = $config; 31 | } 32 | 33 | /** 34 | * @param EventManagerInterface $events 35 | */ 36 | public function attach(EventManagerInterface $events, $priority = 1) 37 | { 38 | $this->listeners[] = $events->attach(MvcEvent::EVENT_ROUTE, [$this, 'onRoute'], -100); 39 | } 40 | 41 | /** 42 | * @param MvcEvent $event 43 | * @return void|\Zend\Http\Response 44 | */ 45 | public function onRoute(MvcEvent $event) 46 | { 47 | $request = $event->getRequest(); 48 | if (! $request instanceof Request) { 49 | // Not an HTTP request? nothing to do 50 | return; 51 | } 52 | 53 | $matches = $event->getRouteMatch(); 54 | if (! $matches) { 55 | // No matches, nothing to do 56 | return; 57 | } 58 | 59 | $controller = $matches->getParam('controller', false); 60 | if (! $controller) { 61 | // No controller in the matches, nothing to do 62 | return; 63 | } 64 | 65 | if (! array_key_exists($controller, $this->config)) { 66 | // No matching controller in our configuration, nothing to do 67 | return; 68 | } 69 | 70 | $config = $this->getConfigForControllerAndMatches($this->config[$controller], $matches); 71 | $methods = $this->normalizeMethods($config); 72 | 73 | $method = $request->getMethod(); 74 | if ($method === Request::METHOD_OPTIONS) { 75 | // OPTIONS request? return response with Allow header 76 | return $this->getOptionsResponse($event, $methods); 77 | } 78 | 79 | if (in_array($method, $methods)) { 80 | // Valid HTTP method; nothing to do 81 | return; 82 | } 83 | 84 | // Invalid method; return 405 response 85 | return $this->get405Response($event, $methods); 86 | } 87 | 88 | /** 89 | * Normalize an array of HTTP methods 90 | * 91 | * If a string is provided, create an array with that string. 92 | * 93 | * Ensure all options in the array are UPPERCASE. 94 | * 95 | * @param string|array $methods 96 | * @return array 97 | */ 98 | protected function normalizeMethods($methods) 99 | { 100 | if (is_string($methods)) { 101 | $methods = (array) $methods; 102 | } 103 | 104 | array_walk($methods, function (&$value) { 105 | return strtoupper($value); 106 | }); 107 | return $methods; 108 | } 109 | 110 | /** 111 | * Create the Allow header 112 | * 113 | * @param array $options 114 | * @param Response $response 115 | */ 116 | protected function createAllowHeader(array $options, Response $response) 117 | { 118 | $headers = $response->getHeaders(); 119 | $headers->addHeaderLine('Allow', implode(',', $options)); 120 | } 121 | 122 | /** 123 | * Prepare and return an OPTIONS response 124 | * 125 | * Creates an empty response with an Allow header. 126 | * 127 | * @param MvcEvent $event 128 | * @param array $options 129 | * @return Response 130 | */ 131 | protected function getOptionsResponse(MvcEvent $event, array $options) 132 | { 133 | $response = $event->getResponse(); 134 | $this->createAllowHeader($options, $response); 135 | return $response; 136 | } 137 | 138 | /** 139 | * Prepare a 405 response 140 | * 141 | * @param MvcEvent $event 142 | * @param array $options 143 | * @return Response 144 | */ 145 | protected function get405Response(MvcEvent $event, array $options) 146 | { 147 | $response = $this->getOptionsResponse($event, $options); 148 | $response->setStatusCode(405, 'Method Not Allowed'); 149 | return $response; 150 | } 151 | 152 | /** 153 | * Retrieve the HTTP method configuration for the selected controller and request 154 | * 155 | * Determines if this was a request to a collection or an entity, and returns the 156 | * appropriate HTTP method configuration. 157 | * 158 | * If an entity request was detected, but no entity configuration exists, returns 159 | * 160 | * @param mixed $config 161 | * @param mixed $matches 162 | * @return void 163 | */ 164 | protected function getConfigForControllerAndMatches($config, $matches) 165 | { 166 | $collectionConfig = []; 167 | if (array_key_exists('collection_http_methods', $config) 168 | && is_array($config['collection_http_methods']) 169 | ) { 170 | $collectionConfig = $config['collection_http_methods']; 171 | // Ensure the HTTP method names are normalized 172 | array_walk($collectionConfig, function (&$value) { 173 | $value = strtoupper($value); 174 | }); 175 | } 176 | 177 | $identifier = false; 178 | if (array_key_exists('route_identifier_name', $config)) { 179 | $identifier = $config['route_identifier_name']; 180 | } 181 | 182 | if (! $identifier || $matches->getParam($identifier, false) === false) { 183 | return $collectionConfig; 184 | } 185 | 186 | if (array_key_exists('entity_http_methods', $config) 187 | && is_array($config['entity_http_methods']) 188 | ) { 189 | $entityConfig = $config['entity_http_methods']; 190 | // Ensure the HTTP method names are normalized 191 | array_walk($entityConfig, function (&$value) { 192 | $value = strtoupper($value); 193 | }); 194 | return $entityConfig; 195 | } 196 | 197 | return []; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Listener/RestParametersListener.php: -------------------------------------------------------------------------------- 1 | listeners[] = $events->attach(MvcEvent::EVENT_DISPATCH, [$this, 'onDispatch'], 100); 31 | } 32 | 33 | /** 34 | * @param SharedEventManagerInterface $events 35 | */ 36 | public function attachShared(SharedEventManagerInterface $events) 37 | { 38 | $listener = $events->attach( 39 | RestController::class, 40 | MvcEvent::EVENT_DISPATCH, 41 | [$this, 'onDispatch'], 42 | 100 43 | ); 44 | 45 | if (! $listener) { 46 | $listener = [$this, 'onDispatch']; 47 | } 48 | 49 | $this->sharedListeners[] = $listener; 50 | } 51 | 52 | /** 53 | * @param SharedEventManagerInterface $events 54 | */ 55 | public function detachShared(SharedEventManagerInterface $events) 56 | { 57 | $eventManagerVersion = method_exists($events, 'getEvents') ? 2 : 3; 58 | foreach ($this->sharedListeners as $index => $listener) { 59 | switch ($eventManagerVersion) { 60 | case 2: 61 | if ($events->detach(RestController::class, $listener)) { 62 | unset($this->sharedListeners[$index]); 63 | } 64 | break; 65 | case 3: 66 | if ($events->detach($listener, RestController::class, MvcEvent::EVENT_DISPATCH)) { 67 | unset($this->sharedListeners[$index]); 68 | } 69 | break; 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * Listen to the dispatch event 76 | * 77 | * @param MvcEvent $e 78 | */ 79 | public function onDispatch(MvcEvent $e) 80 | { 81 | $controller = $e->getTarget(); 82 | if (! $controller instanceof RestController) { 83 | return; 84 | } 85 | 86 | $request = $e->getRequest(); 87 | $query = $request->getQuery(); 88 | $matches = $e->getRouteMatch(); 89 | $resource = $controller->getResource(); 90 | $resource->setQueryParams($query); 91 | $resource->setRouteMatch($matches); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Module.php: -------------------------------------------------------------------------------- 1 | getTarget(); 37 | $services = $app->getServiceManager(); 38 | $events = $app->getEventManager(); 39 | 40 | $services->get('ZF\Rest\OptionsListener')->attach($events); 41 | 42 | $sharedEvents = $events->getSharedManager(); 43 | $services->get('ZF\Rest\RestParametersListener')->attachShared($sharedEvents); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Resource.php: -------------------------------------------------------------------------------- 1 | params = $params; 69 | return $this; 70 | } 71 | 72 | /** 73 | * @return array 74 | */ 75 | public function getEventParams() 76 | { 77 | return $this->params; 78 | } 79 | 80 | /** 81 | * @param null|IdentityInterface $identity 82 | * @return self 83 | */ 84 | public function setIdentity(IdentityInterface $identity = null) 85 | { 86 | $this->identity = $identity; 87 | return $this; 88 | } 89 | 90 | /** 91 | * @return null|IdentityInterface 92 | */ 93 | public function getIdentity() 94 | { 95 | return $this->identity; 96 | } 97 | 98 | /** 99 | * @param null|InputFilterInterface $inputFilter 100 | * @return self 101 | */ 102 | public function setInputFilter(InputFilterInterface $inputFilter = null) 103 | { 104 | $this->inputFilter = $inputFilter; 105 | return $this; 106 | } 107 | 108 | /** 109 | * @return null|InputFilterInterface 110 | */ 111 | public function getInputFilter() 112 | { 113 | return $this->inputFilter; 114 | } 115 | 116 | /** 117 | * @param Parameters $params 118 | * @return self 119 | */ 120 | public function setQueryParams(Parameters $params) 121 | { 122 | $this->queryParams = $params; 123 | return $this; 124 | } 125 | 126 | /** 127 | * @return null|Parameters 128 | */ 129 | public function getQueryParams() 130 | { 131 | return $this->queryParams; 132 | } 133 | 134 | /** 135 | * @param RouteMatch|V2RouteMatch $matches 136 | * @return self 137 | */ 138 | public function setRouteMatch($matches) 139 | { 140 | if (! ($matches instanceof RouteMatch || $matches instanceof V2RouteMatch)) { 141 | throw new InvalidArgumentException(sprintf( 142 | '%s expects a %s or %s instance; received %s', 143 | __METHOD__, 144 | RouteMatch::class, 145 | V2RouteMatch::class, 146 | (is_object($matches) ? get_class($matches) : gettype($matches)) 147 | )); 148 | } 149 | $this->routeMatch = $matches; 150 | return $this; 151 | } 152 | 153 | /** 154 | * @return null|RouteMatch|V2RouteMatch 155 | */ 156 | public function getRouteMatch() 157 | { 158 | return $this->routeMatch; 159 | } 160 | 161 | /** 162 | * @param string $name 163 | * @param mixed $value 164 | * @return self 165 | */ 166 | public function setEventParam($name, $value) 167 | { 168 | $this->params[$name] = $value; 169 | return $this; 170 | } 171 | 172 | /** 173 | * @param mixed $name 174 | * @param mixed $default 175 | * @return mixed 176 | */ 177 | public function getEventParam($name, $default = null) 178 | { 179 | if (isset($this->params[$name])) { 180 | return $this->params[$name]; 181 | } 182 | 183 | return $default; 184 | } 185 | 186 | /** 187 | * Set event manager instance 188 | * 189 | * Sets the event manager identifiers to the current class, this class, and 190 | * the resource interface. 191 | * 192 | * @param EventManagerInterface $events 193 | * @return self 194 | */ 195 | public function setEventManager(EventManagerInterface $events) 196 | { 197 | $events->addIdentifiers([ 198 | get_class($this), 199 | __CLASS__, 200 | 'ZF\Rest\ResourceInterface', 201 | ]); 202 | $this->events = $events; 203 | return $this; 204 | } 205 | 206 | /** 207 | * Retrieve event manager 208 | * 209 | * Lazy-instantiates an EM instance if none provided. 210 | * 211 | * @return EventManagerInterface 212 | */ 213 | public function getEventManager() 214 | { 215 | if (! $this->events) { 216 | $this->setEventManager(new EventManager()); 217 | } 218 | return $this->events; 219 | } 220 | 221 | /** 222 | * Create a record in the resource 223 | * 224 | * Expects either an array or object representing the item to create. If 225 | * a non-array, non-object is provided, raises an exception. 226 | * 227 | * The value returned by the last listener to the "create" event will be 228 | * returned as long as it is an array or object; otherwise, the original 229 | * $data is returned. If you wish to indicate failure to create, raise a 230 | * ZF\Rest\Exception\CreationException from a listener. 231 | * 232 | * @param array|object $data 233 | * @return array|object 234 | * @throws Exception\InvalidArgumentException 235 | */ 236 | public function create($data) 237 | { 238 | if (is_array($data)) { 239 | $data = (object) $data; 240 | } 241 | if (! is_object($data)) { 242 | throw new Exception\InvalidArgumentException(sprintf( 243 | 'Data provided to create must be either an array or object; received "%s"', 244 | gettype($data) 245 | )); 246 | } 247 | 248 | $results = $this->triggerEvent(__FUNCTION__, ['data' => $data]); 249 | $last = $results->last(); 250 | if (! is_array($last) && ! is_object($last)) { 251 | return $data; 252 | } 253 | return $last; 254 | } 255 | 256 | /** 257 | * Update (replace) an existing item 258 | * 259 | * Updates the item indicated by $id, replacing it with the information 260 | * in $data. $data should be a full representation of the item, and should 261 | * be an array or object; if otherwise, an exception will be raised. 262 | * 263 | * Like create(), the return value of the last executed listener will be 264 | * returned, as long as it is an array or object; otherwise, $data is 265 | * returned. If you wish to indicate failure to update, raise a 266 | * ZF\Rest\Exception\UpdateException. 267 | * 268 | * @param string|int $id 269 | * @param array|object $data 270 | * @return array|object 271 | * @throws Exception\InvalidArgumentException 272 | */ 273 | public function update($id, $data) 274 | { 275 | if (is_array($data)) { 276 | $data = (object) $data; 277 | } 278 | if (! is_object($data)) { 279 | throw new Exception\InvalidArgumentException(sprintf( 280 | 'Data provided to update must be either an array or object; received "%s"', 281 | gettype($data) 282 | )); 283 | } 284 | 285 | $results = $this->triggerEvent(__FUNCTION__, compact('id', 'data')); 286 | $last = $results->last(); 287 | if (! is_array($last) && ! is_object($last)) { 288 | return $data; 289 | } 290 | return $last; 291 | } 292 | 293 | /** 294 | * Update (replace) an existing collection of items 295 | * 296 | * Replaces the collection with the items contained in $data. 297 | * $data should be a multidimensional array or array of objects; if 298 | * otherwise, an exception will be raised. 299 | * 300 | * Like update(), the return value of the last executed listener will be 301 | * returned, as long as it is an array or object; otherwise, $data is 302 | * returned. If you wish to indicate failure to update, raise a 303 | * ZF\Rest\Exception\UpdateException. 304 | * 305 | * @param array $data 306 | * @return array|object 307 | * @throws Exception\InvalidArgumentException 308 | */ 309 | public function replaceList($data) 310 | { 311 | if (! is_array($data)) { 312 | throw new Exception\InvalidArgumentException(sprintf( 313 | 'Data provided to replaceList must be either a multi-dimensional array ' 314 | . 'or array of objects; received "%s"', 315 | gettype($data) 316 | ), 400); 317 | } 318 | 319 | array_walk($data, function ($value, $key) use (&$data) { 320 | if (is_array($value)) { 321 | $data[$key] = (object) $value; 322 | return; 323 | } 324 | 325 | if (! is_object($value)) { 326 | throw new Exception\InvalidArgumentException(sprintf( 327 | 'Data provided to replaceList must contain only arrays or objects; received "%s"', 328 | gettype($value) 329 | ), 400); 330 | } 331 | }); 332 | 333 | $results = $this->triggerEvent(__FUNCTION__, ['data' => $data]); 334 | $last = $results->last(); 335 | if (! is_array($last) && ! is_object($last)) { 336 | return $data; 337 | } 338 | return $last; 339 | } 340 | 341 | /** 342 | * Partial update of an existing item 343 | * 344 | * Update the item indicated by $id, using the information from $data; 345 | * $data should be merged with the existing item in order to provide a 346 | * partial update. Additionally, $data should be an array or object; any 347 | * other value will raise an exception. 348 | * 349 | * Like create(), the return value of the last executed listener will be 350 | * returned, as long as it is an array or object; otherwise, $data is 351 | * returned. If you wish to indicate failure to update, raise a 352 | * ZF\Rest\Exception\PatchException. 353 | * 354 | * @param string|int $id 355 | * @param array|object $data 356 | * @return array|object 357 | * @throws Exception\InvalidArgumentException 358 | */ 359 | public function patch($id, $data) 360 | { 361 | if (is_array($data)) { 362 | $data = (object) $data; 363 | } 364 | if (! is_object($data)) { 365 | throw new Exception\InvalidArgumentException(sprintf( 366 | 'Data provided to patch must be either an array or object; received "%s"', 367 | gettype($data) 368 | )); 369 | } 370 | 371 | $results = $this->triggerEvent(__FUNCTION__, compact('id', 'data')); 372 | $last = $results->last(); 373 | if (! is_array($last) && ! is_object($last)) { 374 | return $data; 375 | } 376 | return $last; 377 | } 378 | 379 | /** 380 | * Patches the collection with the items contained in $data. 381 | * $data should be a multidimensional array or array of objects; if 382 | * otherwise, an exception will be raised. 383 | * 384 | * Like update(), the return value of the last executed listener will be 385 | * returned, as long as it is an array or object; otherwise, $data is 386 | * returned. 387 | * 388 | * As this method can create and update resources, if you wish to indicate 389 | * failure to update, raise a PhlyRestfully\Exception\UpdateException and 390 | * if you wish to indicate a failure to create, raise a 391 | * PhlyRestfully\Exception\CreationException. 392 | * 393 | * @param array $data 394 | * @return array|object 395 | * @throws Exception\InvalidArgumentException 396 | */ 397 | public function patchList($data) 398 | { 399 | if (! is_array($data)) { 400 | throw new Exception\InvalidArgumentException(sprintf( 401 | 'Data provided to patchList must be either a multidimensional array or array of objects; received "%s"', 402 | gettype($data) 403 | ), 400); 404 | } 405 | 406 | $original = $data; 407 | array_walk($data, function ($value, $key) use (&$data) { 408 | if (is_array($value)) { 409 | $data[$key] = new ArrayObject($value); 410 | return; 411 | } 412 | 413 | if (! is_object($value)) { 414 | throw new Exception\InvalidArgumentException(sprintf( 415 | 'Data provided to patchList must contain only arrays or objects; received "%s"', 416 | gettype($value) 417 | ), 400); 418 | } 419 | }); 420 | 421 | $data = new ArrayObject($data); 422 | $results = $this->triggerEvent(__FUNCTION__, ['data' => $data]); 423 | $last = $results->last(); 424 | if (! is_array($last) && ! is_object($last)) { 425 | return $original; 426 | } 427 | return $last; 428 | } 429 | 430 | /** 431 | * Delete an existing item 432 | * 433 | * Use to delete the item indicated by $id. The value returned by the last 434 | * listener will be used, as long as it is a boolean; otherwise, a boolean 435 | * false will be returned, indicating failure to delete. 436 | * 437 | * @param string|int $id 438 | * @return bool 439 | */ 440 | public function delete($id) 441 | { 442 | $results = $this->triggerEvent(__FUNCTION__, ['id' => $id]); 443 | $last = $results->last(); 444 | if (! is_bool($last) 445 | && ! $last instanceof ApiProblem 446 | && ! $last instanceof ApiProblemResponse 447 | && ! $last instanceof Response 448 | ) { 449 | return false; 450 | } 451 | return $last; 452 | } 453 | 454 | /** 455 | * Delete an existing collection of records 456 | * 457 | * @param null|array $data 458 | * @return bool 459 | */ 460 | public function deleteList($data = null) 461 | { 462 | if ($data 463 | && (! is_array($data) && ! $data instanceof Traversable) 464 | ) { 465 | throw new Exception\InvalidArgumentException(sprintf( 466 | '%s expects a null argument, or an array/Traversable of items and/or ids; received %s', 467 | __METHOD__, 468 | gettype($data) 469 | )); 470 | } 471 | 472 | $results = $this->triggerEvent(__FUNCTION__, ['data' => $data]); 473 | $last = $results->last(); 474 | if (! is_bool($last) 475 | && ! $last instanceof ApiProblem 476 | && ! $last instanceof ApiProblemResponse 477 | && ! $last instanceof Response 478 | ) { 479 | return false; 480 | } 481 | return $last; 482 | } 483 | 484 | /** 485 | * Fetch an existing item 486 | * 487 | * Retrieve an existing item indicated by $id. The value of the last 488 | * listener will be returned, as long as it is an array or object; 489 | * otherwise, a boolean false value will be returned, indicating a 490 | * lookup failure. 491 | * 492 | * @param string|int $id 493 | * @return false|array|object 494 | */ 495 | public function fetch($id) 496 | { 497 | $results = $this->triggerEvent(__FUNCTION__, ['id' => $id]); 498 | $last = $results->last(); 499 | if (! is_array($last) && ! is_object($last)) { 500 | return false; 501 | } 502 | return $last; 503 | } 504 | 505 | /** 506 | * Fetch a collection of items 507 | * 508 | * Use to retrieve a collection of items. The value of the last 509 | * listener will be returned, as long as it is an array or Traversable; 510 | * otherwise, an empty array will be returned. 511 | * 512 | * The recommendation is to return a \Zend\Paginator\Paginator instance, 513 | * which will allow performing paginated sets, and thus allow the view 514 | * layer to select the current page based on the query string or route. 515 | * 516 | * @return array|Traversable 517 | */ 518 | public function fetchAll() 519 | { 520 | $params = func_get_args(); 521 | $results = $this->triggerEvent(__FUNCTION__, $params); 522 | $last = $results->last(); 523 | if (! is_array($last) 524 | && ! $last instanceof HalCollection 525 | && ! $last instanceof ApiProblem 526 | && ! $last instanceof ApiProblemResponse 527 | && ! is_object($last) 528 | ) { 529 | return []; 530 | } 531 | return $last; 532 | } 533 | 534 | /** 535 | * @param string $name 536 | * @param array $args 537 | * @return \Zend\EventManager\ResponseCollection 538 | */ 539 | protected function triggerEvent($name, array $args) 540 | { 541 | return $this->getEventManager()->triggerEventUntil(function ($result) { 542 | return ($result instanceof ApiProblem 543 | || $result instanceof ApiProblemResponse 544 | || $result instanceof Response 545 | ); 546 | }, $this->prepareEvent($name, $args)); 547 | } 548 | 549 | /** 550 | * Prepare event parameters 551 | * 552 | * Merges any event parameters set in the resources with arguments passed 553 | * to a resource method, and passes them to the `prepareArgs` method of the 554 | * event manager. 555 | * 556 | * If an input filter is composed, this, too, is injected into the event. 557 | * 558 | * @param string $name 559 | * @param array $args 560 | * @return ResourceEvent 561 | */ 562 | protected function prepareEvent($name, array $args) 563 | { 564 | $event = new ResourceEvent($name, $this, $this->prepareEventParams($args)); 565 | $event->setIdentity($this->getIdentity()); 566 | $event->setInputFilter($this->getInputFilter()); 567 | $event->setQueryParams($this->getQueryParams()); 568 | $event->setRouteMatch($this->getRouteMatch()); 569 | 570 | return $event; 571 | } 572 | 573 | /** 574 | * Prepare event parameters 575 | * 576 | * Ensures event parameters are created as an array object, allowing them to be modified 577 | * by listeners and retrieved. 578 | * 579 | * @param array $args 580 | * @return ArrayObject 581 | */ 582 | protected function prepareEventParams(array $args) 583 | { 584 | $defaultParams = $this->getEventParams(); 585 | $params = array_merge($defaultParams, $args); 586 | if (empty($params)) { 587 | return $params; 588 | } 589 | 590 | return $this->getEventManager()->prepareArgs($params); 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /src/ResourceEvent.php: -------------------------------------------------------------------------------- 1 | setRequest($params['request']); 65 | unset($params['request']); 66 | } 67 | } 68 | 69 | parent::setParams($params); 70 | return $this; 71 | } 72 | 73 | /** 74 | * @param null|IdentityInterface $identity 75 | * @return self 76 | */ 77 | public function setIdentity(IdentityInterface $identity = null) 78 | { 79 | $this->identity = $identity; 80 | return $this; 81 | } 82 | 83 | /** 84 | * @return null|IdentityInterface 85 | */ 86 | public function getIdentity() 87 | { 88 | return $this->identity; 89 | } 90 | 91 | /** 92 | * @param null|InputFilterInterface $inputFilter 93 | * @return self 94 | */ 95 | public function setInputFilter(InputFilterInterface $inputFilter = null) 96 | { 97 | $this->inputFilter = $inputFilter; 98 | return $this; 99 | } 100 | 101 | /** 102 | * @return null|InputFilterInterface 103 | */ 104 | public function getInputFilter() 105 | { 106 | return $this->inputFilter; 107 | } 108 | 109 | /** 110 | * @param Parameters $params 111 | * @return self 112 | */ 113 | public function setQueryParams(Parameters $params = null) 114 | { 115 | $this->queryParams = $params; 116 | return $this; 117 | } 118 | 119 | /** 120 | * @return null|Parameters 121 | */ 122 | public function getQueryParams() 123 | { 124 | return $this->queryParams; 125 | } 126 | 127 | /** 128 | * Retrieve a single query parameter by name 129 | * 130 | * If not present, returns the $default value provided. 131 | * 132 | * @param string $name 133 | * @param mixed $default 134 | * @return mixed 135 | */ 136 | public function getQueryParam($name, $default = null) 137 | { 138 | $params = $this->getQueryParams(); 139 | if (null === $params) { 140 | return $default; 141 | } 142 | 143 | return $params->get($name, $default); 144 | } 145 | 146 | /** 147 | * @param null|RequestInterface $request 148 | * @return self 149 | */ 150 | public function setRequest(RequestInterface $request = null) 151 | { 152 | $this->request = $request; 153 | return $this; 154 | } 155 | 156 | /** 157 | * @return null|RequestInterface 158 | */ 159 | public function getRequest() 160 | { 161 | return $this->request; 162 | } 163 | 164 | /** 165 | * @param RouteMatch|V2RouteMatch $matches 166 | * @return self 167 | */ 168 | public function setRouteMatch($matches = null) 169 | { 170 | if (null !== $matches && ! ($matches instanceof RouteMatch || $matches instanceof V2RouteMatch)) { 171 | throw new InvalidArgumentException(sprintf( 172 | '%s expects a null or %s or %s instances; received %s', 173 | __METHOD__, 174 | RouteMatch::class, 175 | V2RouteMatch::class, 176 | (is_object($matches) ? get_class($matches) : gettype($matches)) 177 | )); 178 | } 179 | $this->routeMatch = $matches; 180 | return $this; 181 | } 182 | 183 | /** 184 | * @return null|RouteMatch|V2RouteMatch 185 | */ 186 | public function getRouteMatch() 187 | { 188 | return $this->routeMatch; 189 | } 190 | 191 | /** 192 | * Retrieve a single route match parameter by name. 193 | * 194 | * If not present, returns the $default value provided. 195 | * 196 | * @param string $name 197 | * @param mixed $default 198 | * @return mixed 199 | */ 200 | public function getRouteParam($name, $default = null) 201 | { 202 | $matches = $this->getRouteMatch(); 203 | if (null === $matches) { 204 | return $default; 205 | } 206 | 207 | return $matches->getParam($name, $default); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/ResourceInterface.php: -------------------------------------------------------------------------------- 1 | eventIdentifier = $eventIdentifier; 142 | } 143 | } 144 | 145 | /** 146 | * Set the allowed HTTP methods for collections 147 | * 148 | * @param array $methods 149 | */ 150 | public function setCollectionHttpMethods(array $methods) 151 | { 152 | $this->collectionHttpMethods = $methods; 153 | } 154 | 155 | /** 156 | * Set the name to which to assign a collection in a Collection 157 | * 158 | * @param string $name 159 | */ 160 | public function setCollectionName($name) 161 | { 162 | $this->collectionName = (string) $name; 163 | } 164 | 165 | /** 166 | * Set the minimum page size for paginated responses 167 | * 168 | * @param int 169 | */ 170 | public function setMinPageSize($count) 171 | { 172 | $this->minPageSize = (int) $count; 173 | } 174 | 175 | /** 176 | * Return the minimum page size 177 | * 178 | * @return int 179 | */ 180 | public function getMinPageSize() 181 | { 182 | return $this->minPageSize; 183 | } 184 | 185 | /** 186 | * Set the default page size for paginated responses 187 | * 188 | * @param int 189 | */ 190 | public function setPageSize($count) 191 | { 192 | $this->pageSize = (int) $count; 193 | } 194 | 195 | /** 196 | * Return the default page size 197 | * 198 | * @return int 199 | */ 200 | public function getPageSize() 201 | { 202 | return $this->pageSize; 203 | } 204 | 205 | /** 206 | * Set the maximum page size for paginated responses 207 | * 208 | * @param int 209 | */ 210 | public function setMaxPageSize($count) 211 | { 212 | $this->maxPageSize = (int) $count; 213 | } 214 | 215 | /** 216 | * Return the maximum page size 217 | * 218 | * @return int 219 | */ 220 | public function getMaxPageSize() 221 | { 222 | return $this->maxPageSize; 223 | } 224 | 225 | /** 226 | * Set the page size parameter for paginated responses. 227 | * 228 | * @param string 229 | */ 230 | public function setPageSizeParam($param) 231 | { 232 | $this->pageSizeParam = (string) $param; 233 | } 234 | 235 | /** 236 | * The true description of getIdentifierName is 237 | * a route identifier name. This function corrects 238 | * this mistake for this controller. 239 | * 240 | * @return string 241 | */ 242 | public function getRouteIdentifierName() 243 | { 244 | return $this->getIdentifierName(); 245 | } 246 | 247 | /** 248 | * Inject the resource with which this controller will communicate. 249 | * 250 | * @param ResourceInterface $resource 251 | */ 252 | public function setResource(ResourceInterface $resource) 253 | { 254 | $this->resource = $resource; 255 | } 256 | 257 | /** 258 | * Returns the resource 259 | * 260 | * @throws DomainException If no resource has been set 261 | * 262 | * @return ResourceInterface 263 | */ 264 | public function getResource() 265 | { 266 | if ($this->resource === null) { 267 | throw new DomainException('No resource has been set.'); 268 | } 269 | 270 | $this->injectEventIdentityIntoResource(); 271 | $this->injectEventInputFilterIntoResource(); 272 | $this->injectRequestIntoResourceEventParams(); 273 | return $this->resource; 274 | } 275 | 276 | /** 277 | * Set the allowed HTTP OPTIONS for a resource 278 | * 279 | * @param array $methods 280 | */ 281 | public function setEntityHttpMethods(array $methods) 282 | { 283 | $this->entityHttpMethods = $methods; 284 | } 285 | 286 | /** 287 | * Inject the route name for this resource. 288 | * 289 | * @param string $route 290 | */ 291 | public function setRoute($route) 292 | { 293 | $this->route = $route; 294 | } 295 | 296 | /** 297 | * Handle the dispatch event 298 | * 299 | * Does several "pre-flight" checks: 300 | * - Raises an exception if no resource is composed. 301 | * - Raises an exception if no route is composed. 302 | * - Returns a 405 response if the current HTTP request method is not in 303 | * $options 304 | * 305 | * When the dispatch is complete, it will check to see if an array was 306 | * returned; if so, it will cast it to a view model using the 307 | * AcceptableViewModelSelector plugin, and the $acceptCriteria property. 308 | * 309 | * @param MvcEvent $e 310 | * @return mixed 311 | * @throws DomainException 312 | */ 313 | public function onDispatch(MvcEvent $e) 314 | { 315 | if (! $this->getResource()) { 316 | throw new DomainException(sprintf( 317 | '%s requires that a %s\ResourceInterface object is composed; none provided', 318 | __CLASS__, 319 | __NAMESPACE__ 320 | )); 321 | } 322 | 323 | if (! $this->route) { 324 | throw new DomainException(sprintf( 325 | '%s requires that a route name for the resource is composed; none provided', 326 | __CLASS__ 327 | )); 328 | } 329 | 330 | // Check for an API-Problem in the event 331 | $return = $e->getParam('api-problem', false); 332 | 333 | // If no return value dispatch the parent event 334 | if (! $return) { 335 | $return = parent::onDispatch($e); 336 | } 337 | 338 | if (! $return instanceof ApiProblem 339 | && ! $return instanceof HalEntity 340 | && ! $return instanceof HalCollection 341 | ) { 342 | return $return; 343 | } 344 | 345 | if ($return instanceof ApiProblem) { 346 | return new ApiProblemResponse($return); 347 | } 348 | 349 | // Set the fallback content negotiation to use HalJson. 350 | $e->setParam('ZFContentNegotiationFallback', 'HalJson'); 351 | 352 | // Use content negotiation for creating the view model 353 | $viewModel = new ContentNegotiationViewModel(['payload' => $return]); 354 | $e->setResult($viewModel); 355 | 356 | return $viewModel; 357 | } 358 | 359 | /** 360 | * Create a new entity 361 | * 362 | * @todo Remove 'resource' from the create.post event parameters for 1.0.0 363 | * @param array $data 364 | * @return Response|ApiProblem|ApiProblemResponse|HalEntity 365 | */ 366 | public function create($data) 367 | { 368 | $events = $this->getEventManager(); 369 | $events->trigger('create.pre', $this, ['data' => $data]); 370 | 371 | try { 372 | $value = $this->getResource()->create($data); 373 | } catch (Throwable $e) { 374 | return $this->createApiProblemFromException($e); 375 | } catch (Exception $e) { 376 | return $this->createApiProblemFromException($e); 377 | } 378 | 379 | if ($this->isPreparedResponse($value)) { 380 | return $value; 381 | } 382 | 383 | if ($value instanceof HalCollection) { 384 | $halCollection = $this->prepareHalCollection($value); 385 | 386 | $events->trigger('create.post', $this, [ 387 | 'data' => $data, 388 | 'entity' => $halCollection, 389 | 'collection' => $halCollection, 390 | 'resource' => $halCollection, 391 | ]); 392 | 393 | return $halCollection; 394 | } 395 | 396 | $halEntity = $this->createHalEntity($value); 397 | 398 | if ($halEntity->getLinks()->has('self')) { 399 | $plugin = $this->plugin('Hal'); 400 | $link = $halEntity->getLinks()->get('self'); 401 | $self = $plugin->fromLink($link); 402 | $url = $self['href']; 403 | 404 | $response = $this->getResponse(); 405 | $response->setStatusCode(201); 406 | $response->getHeaders()->addHeaderLine('Location', $url); 407 | $response->getHeaders()->addHeaderLine('Content-Location', $url); 408 | } 409 | 410 | $events->trigger('create.post', $this, [ 411 | 'data' => $data, 412 | 'entity' => $halEntity, 413 | 'resource' => $halEntity, 414 | ]); 415 | 416 | return $halEntity; 417 | } 418 | 419 | /** 420 | * Delete an existing entity 421 | * 422 | * @param int|string $id 423 | * @return Response|ApiProblem|ApiProblemResponse 424 | */ 425 | public function delete($id) 426 | { 427 | $events = $this->getEventManager(); 428 | $events->trigger('delete.pre', $this, ['id' => $id]); 429 | 430 | try { 431 | $result = $this->getResource()->delete($id); 432 | } catch (Throwable $e) { 433 | return $this->createApiProblemFromException($e); 434 | } catch (Exception $e) { 435 | return $this->createApiProblemFromException($e); 436 | } 437 | 438 | $result = $result ?: new ApiProblem(422, 'Unable to delete entity.'); 439 | 440 | if ($this->isPreparedResponse($result)) { 441 | return $result; 442 | } 443 | 444 | $response = $this->getResponse(); 445 | $response->setStatusCode(204); 446 | 447 | $events->trigger('delete.post', $this, ['id' => $id]); 448 | 449 | return $response; 450 | } 451 | 452 | /** 453 | * Delete a collection of entities as specified. 454 | * 455 | * @param mixed $data Typically an array 456 | * @return Response|ApiProblem|ApiProblemResponse 457 | */ 458 | public function deleteList($data) 459 | { 460 | $events = $this->getEventManager(); 461 | $events->trigger('deleteList.pre', $this, []); 462 | 463 | try { 464 | $result = $this->getResource()->deleteList($data); 465 | } catch (Throwable $e) { 466 | return $this->createApiProblemFromException($e); 467 | } catch (Exception $e) { 468 | return $this->createApiProblemFromException($e); 469 | } 470 | 471 | $result = $result ?: new ApiProblem(422, 'Unable to delete collection.'); 472 | 473 | if ($this->isPreparedResponse($result)) { 474 | return $result; 475 | } 476 | 477 | $response = $this->getResponse(); 478 | $response->setStatusCode(204); 479 | 480 | $events->trigger('deleteList.post', $this, []); 481 | 482 | return $response; 483 | } 484 | 485 | /** 486 | * Return single entity 487 | * 488 | * @todo Remove 'resource' from get.post event for 1.0.0 489 | * @param int|string $id 490 | * @return Response|ApiProblem|ApiProblemResponse|HalEntity 491 | */ 492 | public function get($id) 493 | { 494 | $events = $this->getEventManager(); 495 | $events->trigger('get.pre', $this, ['id' => $id]); 496 | 497 | try { 498 | $entity = $this->getResource()->fetch($id); 499 | } catch (Throwable $e) { 500 | return $this->createApiProblemFromException($e); 501 | } catch (Exception $e) { 502 | return $this->createApiProblemFromException($e); 503 | } 504 | 505 | $entity = $entity ?: new ApiProblem(404, 'Entity not found.'); 506 | 507 | if ($this->isPreparedResponse($entity)) { 508 | return $entity; 509 | } 510 | 511 | $halEntity = $this->createHalEntity($entity); 512 | 513 | $events->trigger('get.post', $this, [ 514 | 'id' => $id, 515 | 'entity' => $halEntity, 516 | 'resource' => $halEntity, 517 | ]); 518 | 519 | return $halEntity; 520 | } 521 | 522 | /** 523 | * Return collection of entities 524 | * 525 | * @return Response|HalCollection|ApiProblem 526 | */ 527 | public function getList() 528 | { 529 | $events = $this->getEventManager(); 530 | $events->trigger('getList.pre', $this, []); 531 | 532 | try { 533 | $collection = $this->getResource()->fetchAll(); 534 | } catch (Throwable $e) { 535 | return $this->createApiProblemFromException($e); 536 | } catch (Exception $e) { 537 | return $this->createApiProblemFromException($e); 538 | } 539 | 540 | if ($this->isPreparedResponse($collection)) { 541 | return $collection; 542 | } 543 | 544 | if (! is_array($collection) 545 | && ! $collection instanceof Traversable 546 | && ! $collection instanceof HalCollection 547 | && is_object($collection) 548 | ) { 549 | $halEntity = $this->createHalEntity($collection); 550 | $events->trigger('getList.post', $this, ['collection' => $halEntity]); 551 | return $halEntity; 552 | } 553 | 554 | $pageSize = $this->pageSizeParam 555 | ? $this->getRequest()->getQuery($this->pageSizeParam, $this->pageSize) 556 | : $this->pageSize; 557 | 558 | if (isset($this->minPageSize) && $pageSize < $this->minPageSize) { 559 | return new ApiProblem( 560 | 416, 561 | sprintf("Page size is out of range, minimum page size is %s", $this->minPageSize) 562 | ); 563 | } 564 | 565 | if (isset($this->maxPageSize) && $pageSize > $this->maxPageSize) { 566 | return new ApiProblem( 567 | 416, 568 | sprintf("Page size is out of range, maximum page size is %s", $this->maxPageSize) 569 | ); 570 | } 571 | 572 | $this->setPageSize($pageSize); 573 | 574 | $halCollection = $this->createHalCollection($collection); 575 | 576 | if ($this->isPreparedResponse($halCollection)) { 577 | return $halCollection; 578 | } 579 | 580 | $events->trigger('getList.post', $this, [ 581 | 'collection' => $halCollection, 582 | ]); 583 | 584 | return $halCollection; 585 | } 586 | 587 | /** 588 | * Retrieve HEAD metadata for the entity and/or collection 589 | * 590 | * @param null|mixed $id 591 | * @return Response|ApiProblem|ApiProblemResponse|HalEntity|HalCollection 592 | */ 593 | public function head($id = null) 594 | { 595 | if ($id) { 596 | return $this->get($id); 597 | } 598 | return $this->getList(); 599 | } 600 | 601 | /** 602 | * Respond to OPTIONS request 603 | * 604 | * Uses $options to set the Allow header line and return an empty response. 605 | * 606 | * @return Response 607 | */ 608 | public function options() 609 | { 610 | $e = $this->getEvent(); 611 | $id = $this->getIdentifier($e->getRouteMatch(), $e->getRequest()); 612 | 613 | if ($id) { 614 | $options = $this->entityHttpMethods; 615 | } else { 616 | $options = $this->collectionHttpMethods; 617 | } 618 | 619 | $events = $this->getEventManager(); 620 | $events->trigger('options.pre', $this, ['options' => $options]); 621 | 622 | $response = $this->getResponse(); 623 | $response->setStatusCode(204); 624 | $headers = $response->getHeaders(); 625 | $headers->addHeader($this->createAllowHeaderWithAllowedMethods($options)); 626 | 627 | $events->trigger('options.post', $this, ['options' => $options]); 628 | 629 | return $response; 630 | } 631 | 632 | /** 633 | * Respond to the PATCH method (partial update of existing entity) 634 | * 635 | * @todo Remove 'resource' from patch.post event for 1.0.0 636 | * @param int|string $id 637 | * @param array $data 638 | * @return Response|ApiProblem|ApiProblemResponse|HalEntity 639 | */ 640 | public function patch($id, $data) 641 | { 642 | $events = $this->getEventManager(); 643 | $events->trigger('patch.pre', $this, ['id' => $id, 'data' => $data]); 644 | 645 | try { 646 | $entity = $this->getResource()->patch($id, $data); 647 | } catch (Throwable $e) { 648 | return $this->createApiProblemFromException($e); 649 | } catch (Exception $e) { 650 | return $this->createApiProblemFromException($e); 651 | } 652 | 653 | if ($this->isPreparedResponse($entity)) { 654 | return $entity; 655 | } 656 | 657 | $halEntity = $this->createHalEntity($entity); 658 | 659 | $events->trigger('patch.post', $this, [ 660 | 'id' => $id, 661 | 'data' => $data, 662 | 'entity' => $halEntity, 663 | 'resource' => $halEntity, 664 | ]); 665 | 666 | return $halEntity; 667 | } 668 | 669 | /** 670 | * Update an existing entity 671 | * 672 | * @todo Remove 'resource' from update.post event for 1.0.0 673 | * @param int|string $id 674 | * @param array $data 675 | * @return Response|ApiProblem|ApiProblemResponse|HalEntity 676 | */ 677 | public function update($id, $data) 678 | { 679 | $events = $this->getEventManager(); 680 | $events->trigger('update.pre', $this, ['id' => $id, 'data' => $data]); 681 | 682 | try { 683 | $entity = $this->getResource()->update($id, $data); 684 | } catch (Throwable $e) { 685 | return $this->createApiProblemFromException($e); 686 | } catch (Exception $e) { 687 | return $this->createApiProblemFromException($e); 688 | } 689 | 690 | if ($this->isPreparedResponse($entity)) { 691 | return $entity; 692 | } 693 | 694 | $halEntity = $this->createHalEntity($entity); 695 | 696 | $events->trigger('update.post', $this, [ 697 | 'id' => $id, 698 | 'data' => $data, 699 | 'entity' => $halEntity, 700 | 'resource' => $halEntity, 701 | ]); 702 | 703 | return $halEntity; 704 | } 705 | 706 | /** 707 | * Respond to the PATCH method (partial update of existing entity) on 708 | * a collection, i.e. create and/or update multiple entities in a collection. 709 | * 710 | * @param array $data 711 | * @return array|ApiProblem 712 | */ 713 | public function patchList($data) 714 | { 715 | $events = $this->getEventManager(); 716 | $events->trigger('patchList.pre', $this, ['data' => $data]); 717 | 718 | try { 719 | $collection = $this->getResource()->patchList($data); 720 | } catch (Throwable $e) { 721 | return $this->createApiProblemFromException($e); 722 | } catch (Exception $e) { 723 | return $this->createApiProblemFromException($e); 724 | } 725 | 726 | if ($this->isPreparedResponse($collection)) { 727 | return $collection; 728 | } 729 | 730 | $halCollection = $this->createHalCollection($collection); 731 | 732 | $events->trigger('patchList.post', $this, [ 733 | 'data' => $data, 734 | 'collection' => $halCollection, 735 | ]); 736 | 737 | return $halCollection; 738 | } 739 | 740 | /** 741 | * Update an existing collection of entities 742 | * 743 | * @param array $data 744 | * @return array|ApiProblem 745 | */ 746 | public function replaceList($data) 747 | { 748 | $events = $this->getEventManager(); 749 | $events->trigger('replaceList.pre', $this, ['data' => $data]); 750 | 751 | try { 752 | $collection = $this->getResource()->replaceList($data); 753 | } catch (Exception\InvalidArgumentException $e) { 754 | return new ApiProblem(400, $e->getMessage()); 755 | } catch (Throwable $e) { 756 | return $this->createApiProblemFromException($e); 757 | } catch (Exception $e) { 758 | return $this->createApiProblemFromException($e); 759 | } 760 | 761 | if ($this->isPreparedResponse($collection)) { 762 | return $collection; 763 | } 764 | 765 | $halCollection = $this->createHalCollection($collection); 766 | 767 | $events->trigger('replaceList.post', $this, [ 768 | 'data' => $data, 769 | 'collection' => $halCollection, 770 | ]); 771 | 772 | return $halCollection; 773 | } 774 | 775 | /** 776 | * Retrieve the identifier, if any 777 | * 778 | * Attempts to see if an identifier was passed in the URI, 779 | * returning it if found. Otherwise, returns a boolean false. 780 | * 781 | * @param \Zend\Mvc\Router\RouteMatch $routeMatch 782 | * @param \Zend\Http\Request $request 783 | * @return false|mixed 784 | */ 785 | protected function getIdentifier($routeMatch, $request) 786 | { 787 | $identifier = $this->getIdentifierName(); 788 | $id = $routeMatch->getParam($identifier, false); 789 | if ($id !== null) { 790 | return $id; 791 | } 792 | 793 | return false; 794 | } 795 | 796 | /** 797 | * Creates an ALLOW header with the provided HTTP methods 798 | * 799 | * @param array $methods 800 | * @return Allow 801 | */ 802 | protected function createAllowHeaderWithAllowedMethods(array $methods) 803 | { 804 | // Need to create an Allow header. It has several defaults, and the only 805 | // way to start with a clean slate is to retrieve all methods, disallow 806 | // them all, and then set the ones we want to allow. 807 | $allow = new Allow(); 808 | $allMethods = $allow->getAllMethods(); 809 | $allow->disallowMethods(array_keys($allMethods)); 810 | $allow->allowMethods($methods); 811 | return $allow; 812 | } 813 | 814 | /** 815 | * @param Exception|Throwable $e 816 | * @return ApiProblem 817 | */ 818 | protected function createApiProblemFromException($e) 819 | { 820 | return new ApiProblem($this->getHttpStatusCodeFromException($e), $e); 821 | } 822 | 823 | /** 824 | * Ensure we have a valid HTTP status code for an ApiProblem 825 | * 826 | * @param Exception|Throwable $e 827 | * @return int 828 | */ 829 | protected function getHttpStatusCodeFromException($e) 830 | { 831 | $code = $e->getCode(); 832 | if (! is_int($code) 833 | || $code < 100 834 | || $code >= 600 835 | ) { 836 | return 500; 837 | } 838 | return $code; 839 | } 840 | 841 | /** 842 | * Injects the resource with the identity composed in the event, if present 843 | */ 844 | protected function injectEventIdentityIntoResource() 845 | { 846 | if ($this->resource->getIdentity()) { 847 | return; 848 | } 849 | 850 | $event = $this->getEvent(); 851 | if (! $event) { 852 | return; 853 | } 854 | 855 | $identity = $event->getParam('ZF\MvcAuth\Identity'); 856 | if (! $identity) { 857 | return; 858 | } 859 | 860 | $this->resource->setIdentity($identity); 861 | } 862 | 863 | /** 864 | * Injects the resource with the input filter composed in the event, if present 865 | */ 866 | protected function injectEventInputFilterIntoResource() 867 | { 868 | if ($this->resource->getInputFilter()) { 869 | return; 870 | } 871 | 872 | $event = $this->getEvent(); 873 | if (! $event) { 874 | return; 875 | } 876 | 877 | $inputFilter = $event->getParam('ZF\ContentValidation\InputFilter'); 878 | if (! $inputFilter) { 879 | return; 880 | } 881 | 882 | $this->resource->setInputFilter($inputFilter); 883 | } 884 | 885 | protected function injectRequestIntoResourceEventParams() 886 | { 887 | $request = $this->getRequest(); 888 | if (! $request) { 889 | return; 890 | } 891 | 892 | $params = $this->resource->getEventParams(); 893 | 894 | if (! is_array($params) && ! $params instanceof ArrayAccess) { 895 | // If not array-like, no clear path for setting event parameters 896 | return; 897 | } 898 | 899 | $params['request'] = $request; 900 | $this->resource->setEventParams($params); 901 | } 902 | 903 | /** 904 | * Override parent - pull from content negotiation helpers 905 | * 906 | * @param RequestInterface $request 907 | * @return null|array|\Traversable 908 | */ 909 | public function processPostData(RequestInterface $request) 910 | { 911 | return $this->create($this->bodyParams()); 912 | } 913 | 914 | /** 915 | * Override parent - pull from content negotiation helpers 916 | * 917 | * @param Request $request 918 | * @return null|array|\Traversable 919 | */ 920 | protected function processBodyContent($request) 921 | { 922 | return $this->bodyParams(); 923 | } 924 | 925 | /** 926 | * @param mixed $object 927 | * @return bool 928 | */ 929 | protected function isPreparedResponse($object) 930 | { 931 | if ($object instanceof ApiProblem 932 | || $object instanceof ApiProblemResponse 933 | || $object instanceof Response 934 | ) { 935 | return true; 936 | } 937 | 938 | return false; 939 | } 940 | 941 | /** 942 | * @param mixed $collection 943 | * @return HalCollection 944 | */ 945 | protected function createHalCollection($collection) 946 | { 947 | if (! $collection instanceof HalCollection) { 948 | $halPlugin = $this->plugin('Hal'); 949 | $collection = $halPlugin->createCollection($collection, $this->route); 950 | } 951 | 952 | return $this->prepareHalCollection($collection); 953 | } 954 | 955 | /** 956 | * Prepare a HAL collection with the metadata for the current instance. 957 | * 958 | * @param HalCollection $collection 959 | * @return HalCollection|ApiProblem 960 | */ 961 | protected function prepareHalCollection(HalCollection $collection) 962 | { 963 | if (! $collection->getLinks()->has('self')) { 964 | $plugin = $this->plugin('Hal'); 965 | $plugin->injectSelfLink($collection, $this->route); 966 | } 967 | 968 | $collection->setCollectionRoute($this->route); 969 | $collection->setRouteIdentifierName($this->getRouteIdentifierName()); 970 | $collection->setEntityRoute($this->route); 971 | $collection->setCollectionName($this->collectionName); 972 | 973 | try { 974 | $collection->setPageSize($this->getPageSize()); 975 | $collection->setPage($this->getRequest()->getQuery('page', 1)); 976 | } catch (HalInvalidArgumentException $e) { 977 | return new ApiProblem(400, $e->getMessage()); 978 | } 979 | 980 | return $collection; 981 | } 982 | 983 | /** 984 | * @param mixed $entity 985 | * @return HalEntity 986 | */ 987 | protected function createHalEntity($entity) 988 | { 989 | if ($entity instanceof HalEntity 990 | && ($entity->getLinks()->has('self') || ! $entity->getId()) 991 | ) { 992 | return $entity; 993 | } 994 | 995 | $plugin = $this->plugin('Hal'); 996 | 997 | return $plugin->createEntity( 998 | $entity, 999 | $this->route, 1000 | $this->getRouteIdentifierName() 1001 | ); 1002 | } 1003 | } 1004 | --------------------------------------------------------------------------------