├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── API ├── BaseController.php ├── BaseModel.php ├── CORSTrait.php ├── Entity.php ├── Output.php ├── Relation.php ├── RelationFilter.php ├── SearchHelper.php ├── SecureController.php └── SimpleSecureController.php ├── Authentication ├── AdapterInterface.php ├── Authenticator.php ├── AuthenticatorInterface.php └── UserProfile.php ├── Exception ├── DatabaseException.php ├── ErrorStore.php ├── HTTPException.php └── ValidationException.php ├── Mvc └── AtomicCollection.php ├── Query ├── QueryBuilder.php └── QueryField.php ├── Request ├── Adapters │ ├── ActiveModel.php │ └── JsonApi.php └── Request.php ├── Result ├── Adapters │ ├── ActiveModel │ │ ├── Data.php │ │ └── Result.php │ └── JsonApi │ │ ├── Data.php │ │ └── Result.php ├── Data.php ├── Relationship.php └── Result.php ├── Rules ├── DenyIfRule.php ├── DenyRule.php ├── FilterRule.php ├── ModelCallbackRule.php ├── QueryRule.php ├── Registry.php └── Store.php ├── Traits └── TableNamespace.php ├── Util └── Inflector.php └── bin ├── base.php ├── bootstrap ├── cli.php └── web.php ├── config.php ├── errorHandler.php ├── loader.php ├── maintenanceRoute.php └── services.php /.gitignore: -------------------------------------------------------------------------------- 1 | # zend studio files 2 | /.buildpath 3 | /.project 4 | /.settings/ 5 | 6 | #composer directory 7 | /vendor/ 8 | 9 | #phpstorm 10 | /.idea 11 | 12 | # zend debugger file 13 | /dummy.php 14 | # ignore codception files 15 | /tests/_output 16 | 17 | /deployment.properties 18 | /deployment.xml 19 | 20 | # ingnore mysql schema builder bak file 21 | *.bak 22 | 23 | # ignore gedit temp files 24 | *.*~ 25 | 26 | #phpstorm 27 | /.idea 28 | 29 | # Octocat's Idea's on what to ignore 30 | # Compiled source # 31 | ################### 32 | *.com 33 | *.class 34 | *.dll 35 | *.exe 36 | *.o 37 | *.so 38 | 39 | # Packages # 40 | ############ 41 | # it's better to unpack these files and commit the raw source 42 | # git has its own built in compression methods 43 | *.7z 44 | *.dmg 45 | *.gz 46 | *.iso 47 | *.jar 48 | *.rar 49 | *.tar 50 | *.zip 51 | 52 | # Logs and databases # 53 | ###################### 54 | *.log 55 | *.sql 56 | # *.sqlite 57 | 58 | # OS generated files # 59 | ###################### 60 | .DS_Store 61 | .DS_Store? 62 | ._* 63 | .Spotlight-V100 64 | .Trashes 65 | ehthumbs.db 66 | Thumbs.db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phalcon-json-api-package 2 | A composer package designed to help you create a JSON:API in Phalcon 3 | 4 | What happens when a PHP developer wants to create an API to drive their client-side SPA? Well you start with [Phalcon](http://phalconphp.com/en/) (A modern super fast framework) loosely follow the [JSON:API](http://jsonapi.org/) and package it up for [Composer](https://getcomposer.org/). The result is the phalcon-json-api package (herafter referred to as the API) so enjoy. 5 | 6 | # System Requirements 7 | - Phalcon 3.x 8 | - SQL persistance layer (ie. MYSQL, MariaDB) Make sure the database is supported by [Phalcon's Database Abstraction Layer](https://docs.phalconphp.com/en/latest/reference/db.html). 9 | - PHP Version 7+ 10 | 11 | # Release Notes 12 | Read up on the latest plans for the API [here](https://github.com/gte451f/phalcon-json-api-package/wiki/Release-Plans). 13 | 14 | # How is Phalcon used? 15 | Phalcon is the underlying framework this project depends on. Any user of the API package will need to have a working installation of Phalcon already installed on their system. The API makes extensive use of Phalcon sub systems including the ORM, Router and Service Locator. 16 | 17 | # How is JSON:API used? 18 | The Phalcon JSON API package attempts to follow the JSON API as closely as possible. There are several enhancements this project incorporates beyond the JSON API specification. 19 | 20 | # How can I quickly see this project in action? 21 | New folks are encouraged to download and install the [sister project](https://github.com/gte451f/phalcon-json-api) that acts as a simple example application to demonstrate how one could use the API. This simple application include all the building blocks that make up the api including use of traditional Phalcon objects like Controllers and Models along with objects designed for use in the API such as Entities, Route and SearchHelpers. 22 | 23 | # How can I install this project? 24 | Aside from meeting the system requirements, you should include this project in your composer file. Here is an example composer file that includes a few extra libraries needed for testing and timing api responses. 25 | 26 | ``` 27 | { 28 | "require": { 29 | "jsanc623/phpbenchtime": "dev-master", 30 | "gte451f/phalcon-json-api-package": "dev-master" 31 | }, 32 | "require-dev": { 33 | "codeception/codeception": "*", 34 | "flow/jsonpath": "dev-master" 35 | } 36 | } 37 | ``` 38 | 39 | # Where is the wiki? 40 | Lots more help is available [here](https://github.com/gte451f/phalcon-json-api-package/wiki). 41 | 42 | # Where do babies come from? 43 | ![The Stork Silly!](http://img2.wikia.nocookie.net/__cb20120518150112/disney/images/2/2f/Dumbo-disneyscreencaps_com-672.jpg "Dumbo Photo") 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gte451f/phalcon-json-api-package", 3 | "description": "A set of tools designed for use in a Phalcon application to make a RESTish API", 4 | "keywords": [ 5 | "phalcon", 6 | "json-api", 7 | "json", 8 | "api", 9 | "rest", 10 | "restful" 11 | ], 12 | "license": "GPL-3.0", 13 | "authors": [ 14 | { 15 | "name": "Jim", 16 | "email": "jim.jenkins.iv@gmail.com", 17 | "role": "Creator" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=7.0", 22 | "ext-phalcon": "^3.0", 23 | "jsanc623/phpbenchtime": "2.*" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "PhalconRest\\": "src/" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/API/BaseController.php: -------------------------------------------------------------------------------- 1 | setDI($di); 57 | 58 | // enforce deny rules right out of the gate 59 | // TODO what about rules like...edit your own records? 60 | 61 | $router = $di->get('router'); 62 | $matchedRoute = $router->getMatchedRoute(); 63 | $method = $matchedRoute->getHttpMethods(); 64 | 65 | 66 | // if a valid model is detected, proceed to work with that model to 1) enforce security rules 2) load entity 67 | $model = $this->getModel(); 68 | if (is_object($model)) { 69 | // GET/POST/PUT/PATCH/DELETE 70 | //load rules and apply to operation 71 | 72 | switch ($method) { 73 | case 'GET': 74 | $mode = READRULES; 75 | break; 76 | case 'POST': 77 | $mode = CREATERULES; 78 | break; 79 | case 'PUT': 80 | case 'PATCH': 81 | $mode = UPDATERULES; 82 | break; 83 | case 'DELETE': 84 | $mode = DELETERULES; 85 | break; 86 | default: 87 | throw new HTTPException('Unsupported operation encountered', 404, [ 88 | 'dev' => 'Encountered operation: ' . $method, 89 | 'code' => '8914681681681681' 90 | ]); 91 | 92 | break; 93 | } 94 | 95 | // expect valid ruleStore since this is run from a controller 96 | $modelRuleStore = $di->get('ruleList')->get($model->getModelName()); 97 | foreach ($modelRuleStore->getRules($mode, 'DenyRule') as $rule) { 98 | // if a deny rule is encountered, block access to this end point 99 | throw new HTTPException('Not authorized to access this end point for this operation:' . $method, 403, [ 100 | 'dev' => 'You do not have access to the requested resource.', 101 | 'code' => '89494186161681864' 102 | ]); 103 | } 104 | // initialize entity and set to class property (doing the same to the model property) 105 | $this->getEntity(); 106 | } 107 | } 108 | 109 | /** 110 | * proxy through which all atomic requests are passed. 111 | * 112 | * set flags to know that the system is dealing with an atomic request 113 | * a transaction is started 114 | * we decide what to do with the transaction: 115 | * - commit (all operations were successful) 116 | * - rollback (a problem occurred) 117 | * 118 | * @param mixed ...$args 119 | * @return mixed 120 | * @throws \Throwable 121 | */ 122 | public function atomicMethod(...$args) 123 | { 124 | $di = $this->getDI(); 125 | $router = $di->get('router'); 126 | $matchedRoute = $router->getMatchedRoute(); 127 | $handler = $matchedRoute->getName(); 128 | $db = $di->get('db'); 129 | $store = $this->getDI()->get('store'); 130 | $db->begin(); 131 | $store->update('transaction_is_atomic', true); 132 | 133 | try { 134 | $result = $this->{$handler}(...$args); 135 | $store->update('rollback_transaction', false); 136 | return $result; 137 | } catch (\Throwable $e) { 138 | $store->update('rollback_transaction', true); 139 | throw $e; 140 | } 141 | } 142 | 143 | /** 144 | * Load a default model unless one is already in place 145 | * return the currently loaded model 146 | * 147 | * @param string|bool $modelNameString 148 | * @return BaseModel 149 | */ 150 | public function getModel($modelNameString = false) 151 | { 152 | // Attempt to load a model using native Phalcon controller API. 153 | if (!$this->model) { 154 | $controllerClass = "\\PhalconRest\Controllers\\" . $this->getControllerName("singular"); 155 | if(class_exists($controllerClass)) { 156 | $controller = new $controllerClass(); 157 | $model = $controller->getModel(); 158 | 159 | if($model) { 160 | $this->model = $model; 161 | } 162 | } 163 | } 164 | 165 | // Backup model loading 166 | if (!$this->model) { 167 | $config = $this->getDI()->get('config'); 168 | // auto load model so we can inject it into the entity 169 | if (!$modelNameString) { 170 | $modelNameString = $this->getControllerName(); 171 | } 172 | 173 | $modelName = $config['namespaces']['models'] . $modelNameString; 174 | $this->model = new $modelName($this->di); 175 | } 176 | return $this->model; 177 | } 178 | 179 | /** 180 | * Load an empty SearchHelper instance. Useful place to override its behavior. 181 | * @return SearchHelper 182 | */ 183 | public function getSearchHelper(): SearchHelper 184 | { 185 | return new SearchHelper(); 186 | } 187 | 188 | /** 189 | * Load a default entity unless a custom version is detected 190 | * return the currently loaded entity 191 | * 192 | * @see $entity 193 | * @return \PhalconRest\API\Entity 194 | */ 195 | public function getEntity() 196 | { 197 | if ($this->entity == false) { 198 | $config = $this->getDI()->get('config'); 199 | $model = $this->getModel(); 200 | $searchHelper = $this->getSearchHelper(); 201 | $entity = $config['namespaces']['entities'] . $this->getControllerName('singular') . 'Entity'; 202 | $entityPath = $config['application']['entitiesDir'] . $this->getControllerName('singular') . 'Entity.php'; 203 | $defaultEntityNameSpace = $config['namespaces']['defaultEntity']; 204 | 205 | //check for file, otherwise load generic entity - it should work just fine 206 | if (file_exists($entityPath)) { 207 | $entity = new $entity($model, $searchHelper); 208 | } else { 209 | $entity = new $defaultEntityNameSpace($model, $searchHelper); 210 | } 211 | $this->entity = $this->configureEntity($entity); 212 | } 213 | return $this->entity; 214 | } 215 | 216 | /** 217 | * Hook that allows child controller to manipulate entity early in request life cycle 218 | * 219 | * @param Entity $entity 220 | * @return Entity $entity 221 | */ 222 | public function configureEntity(Entity $entity): Entity 223 | { 224 | return $entity; 225 | } 226 | 227 | /** 228 | * get the controllers singular or plural name 229 | * 230 | * @param string $type 231 | * @return string|bool 232 | */ 233 | public function getControllerName($type = 'plural') 234 | { 235 | if ($type == 'singular') { 236 | // auto calc if not already set 237 | if ($this->singularName == null) { 238 | $className = get_called_class(); 239 | $config = $this->getDI()->get('config'); 240 | $className = str_replace($config['namespaces']['controllers'], '', $className); 241 | $className = str_replace('Controller', '', $className); 242 | $this->singularName = $className; 243 | } 244 | return $this->singularName; 245 | } elseif ($type == 'plural') { 246 | // auto calc most common plural 247 | if ($this->pluralName == null) { 248 | // this could be better, just adding an s by default 249 | $this->pluralName = $this->getControllerName('singular') . 's'; 250 | } 251 | return $this->pluralName; 252 | } 253 | 254 | // todo throw error here 255 | return false; 256 | } 257 | 258 | /** 259 | * catches incoming requests for groups of records 260 | * 261 | * @return mixed|\PhalconRest\Result\Result 262 | * @throws HTTPException 263 | */ 264 | public function get() 265 | { 266 | return $this->entity->find(); 267 | } 268 | 269 | /** 270 | * run a limited query for one record 271 | * bypass nearly all normal search params and just search by the primary key 272 | * 273 | * special handling if no matching results are found 274 | * 275 | * @param int $id 276 | * @throws HTTPException 277 | * @return \PhalconRest\Result\Result 278 | */ 279 | public function getOne($id) 280 | { 281 | $result = $this->entity->findFirst($id); 282 | if ($result->countResults() == 0) { 283 | // This is bad. Throw a 500. Responses should always be objects. 284 | throw new HTTPException('Resource not available.', 404, [ 285 | 'dev' => 'The resource you requested is not available.', 286 | 'code' => '43758093745021' 287 | ]); 288 | } else { 289 | return $result; 290 | } 291 | } 292 | 293 | /** 294 | * Attempt to save a record from POST 295 | * This should be saving a new record 296 | * 297 | * @throws HTTPException 298 | * @return mixed return valid Apache code, could be an error, maybe not 299 | * @throws HTTPException 300 | */ 301 | public function post() 302 | { 303 | $request = $this->getDI()->get('request'); 304 | // supply everything the request object could possibly need to fulfill the request 305 | $post = $request->getJson($this->getControllerName('singular'), $this->getModel()); 306 | 307 | if (!$post) { 308 | throw new HTTPException('There was an error adding new record. Missing POST data.', 400, [ 309 | 'dev' => 'Invalid data posted to the server', 310 | 'code' => '568136818916816555' 311 | ]); 312 | } 313 | 314 | if(method_exists($this->model, "getBlockColumns")) { 315 | // filter out any block columns from the posted data 316 | $blockFields = $this->model->getBlockColumns(); 317 | foreach ($blockFields as $key => $value) { 318 | unset($post->$value); 319 | } 320 | } 321 | 322 | $post = $this->beforeSave($post); 323 | // This record only must be created 324 | $id = $this->entity->save($post); 325 | $this->afterSave($post, $id); 326 | 327 | // now fetch the record so we can return it 328 | $result = $this->entity->findFirst($id); 329 | 330 | if ($result->countResults() == 0) { 331 | // This is bad. Throw a 500. Responses should always be objects. 332 | throw new HTTPException('There was an error retrieving the newly created record.', 500, [ 333 | 'dev' => 'The resource you requested is not available after it was just created', 334 | 'code' => '1238510381861' 335 | ]); 336 | } else { 337 | return $result; 338 | } 339 | } 340 | 341 | /** 342 | * Pass through to entity so it can perform extra logic if needed most of the time... 343 | * 344 | * @param int $id 345 | * @throws HTTPException 346 | */ 347 | public function delete($id) 348 | { 349 | $this->beforeDelete($id); 350 | $this->entity->delete($id); 351 | $this->afterDelete($id); 352 | } 353 | 354 | /** 355 | * read in a resource and update it 356 | * 357 | * @param int $id 358 | * @throws HTTPException 359 | * @return \PhalconRest\Result\Result 360 | */ 361 | public function put($id) 362 | { 363 | $request = $this->getDI()->get('request'); 364 | // supply everything the request object could possibly need to fullfill the request 365 | $put = $request->getJson($this->getControllerName('singular'), $this->model); 366 | 367 | if (!$put) { 368 | throw new HTTPException('There was an error updating an existing record.', 500, [ 369 | 'dev' => 'Invalid data posted to the server', 370 | 'code' => '568136818916816' 371 | ]); 372 | } 373 | 374 | if(method_exists($this->model, "getBlockColumns")) { 375 | // filter out any block columns from the posted data 376 | $blockFields = $this->model->getBlockColumns(); 377 | foreach ($blockFields as $key => $value) { 378 | unset($put->$value); 379 | } 380 | } 381 | 382 | $put = $this->beforeSave($put, $id); 383 | $id = $this->entity->save($put, $id); 384 | $this->afterSave($put, $id); 385 | 386 | // reload record so we can return it 387 | $result = $this->entity->findFirst($id); 388 | 389 | if ($result->countResults() == 0) { 390 | // This is bad. Throw a 500. Responses should always be objects. 391 | throw new HTTPException('There was an error retrieving the just updated record.', 500, [ 392 | 'dev' => 'The resource you requested is not available after it was just updated', 393 | 'code' => '1238510381861' 394 | ]); 395 | } else { 396 | return $result; 397 | } 398 | } 399 | 400 | 401 | /** 402 | * hook to be run before a controller calls it's save action 403 | * make it easier to extend default save logic 404 | * 405 | * @param object $object the data submitted to the server 406 | * @param int|null $id the pkid of the record to be updated, otherwise null on inserts 407 | * @return object 408 | */ 409 | public function beforeSave($object, $id = null) 410 | { 411 | // extend me in child class 412 | return $object; 413 | } 414 | 415 | /** 416 | * hook to be run after a controller completes it's save logic 417 | * make it easier to extend default save logic 418 | * 419 | * @param object $object the data submitted to the server (not a model) 420 | * @param int|null $id the pkid of the record to be updated or inserted 421 | */ 422 | public function afterSave($object, $id) 423 | { 424 | // extend me in child class 425 | } 426 | 427 | /** 428 | * hook to be run before a controller performs delete logic 429 | * make it easier to extend default delete logic 430 | * 431 | * @param int $id the record to be deleted 432 | */ 433 | public function beforeDelete($id) 434 | { 435 | // extend me in child class 436 | } 437 | 438 | /** 439 | * hook to be run after a controller performs delete logic 440 | * make it easier to extend default delete logic 441 | * 442 | * @param int $id the id of the record that was just removed 443 | */ 444 | public function afterDelete($id) 445 | { 446 | // extend me in child class 447 | } 448 | 449 | /** 450 | * alias for PUT 451 | * 452 | * @param int $id 453 | * @return \PhalconRest\Result\Result 454 | */ 455 | public function patch($id) 456 | { 457 | // route through PUT logic 458 | return $this->put($id); 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /src/API/CORSTrait.php: -------------------------------------------------------------------------------- 1 | setCorsHeaders($this->response, $this->CORSMethodsBase); 23 | return true; 24 | } 25 | 26 | /** 27 | * Provides a CORS policy for routes like '/users/123' that represent a specific resource 28 | */ 29 | public function optionsOne() 30 | { 31 | $this->setCorsHeaders($this->response, $this->CORSMethodsSingle); 32 | return true; 33 | } 34 | 35 | private function setCorsHeaders(ResponseInterface $response, string $methods) 36 | { 37 | $config = $this->getDI()->get('config'); 38 | $response->setHeader('Access-Control-Allow-Methods', $methods); 39 | $response->setHeader('Access-Control-Allow-Origin', $config['application']['corsOrigin']); 40 | $response->setHeader('Access-Control-Allow-Credentials', 'true'); 41 | $response->setHeader('Access-Control-Allow-Headers', 42 | 'Origin, X-Requested-With, Content-Type, X-Authorization, X-CI-KEY'); 43 | $response->setHeader('Access-Control-Max-Age', '86400'); 44 | } 45 | } -------------------------------------------------------------------------------- /src/API/Output.php: -------------------------------------------------------------------------------- 1 | setDI($di); 46 | } 47 | 48 | /** 49 | * format result set for output to web browser 50 | * add any final meta data 51 | * 52 | * @param Result $result Could be null in case of 204 results 53 | * @return void 54 | */ 55 | public function send(Result $result = null) 56 | { 57 | if ($result) { 58 | // stop timer and add to meta 59 | if ($this->di->get('config')['application']['debugApp'] == true) { 60 | $timer = $this->di->get('stopwatch'); 61 | $timer->end(); 62 | 63 | $summary = [ 64 | 'total_run_time' => round(($timer->endTime - $timer->startTime) * 1000, 2) . ' ms', 65 | 'laps' => [] 66 | ]; 67 | foreach ($timer->laps as $lap) { 68 | $summary['laps'][$lap['name']] = round(($lap['end'] - $lap['start']) * 1000, 2) . ' ms'; 69 | } 70 | $result->addMeta('stopwatch', $summary); 71 | } 72 | 73 | $this->_send($result->outputJSON()); 74 | } else { 75 | $this->setStatusCode(204); 76 | $this->_send(''); 77 | } 78 | 79 | // shouldn't we get out now? 80 | exit(); 81 | } 82 | 83 | /** 84 | * for a given string message, prepare a basic json response for the browser 85 | * 86 | * @param string $message 87 | */ 88 | private function _send($message) 89 | { 90 | // Errors come from HTTPException. This helps set the proper envelope data 91 | /** @var Response $response */ 92 | $response = $this->di->get('response'); 93 | $response->setStatusCode($this->httpCode, $this->httpMessage); 94 | 95 | // HEAD does everything exactly the same as GET, but contains no body 96 | // empty responses (such as a 204 result) should also skip JSON configuration 97 | if ($this->head || !$message) { 98 | $response->setContentType(null); //forces content-type to not be sent 99 | } else { 100 | $jsonConfig = $this->di->get('config')['application']['debugApp'] ? JSON_PRETTY_PRINT : 0; 101 | $response->setJsonContent($message, $jsonConfig); 102 | } 103 | 104 | $response->send(); 105 | } 106 | 107 | /** 108 | * simple setter for properties 109 | * 110 | * @param int $code 111 | * @param string $message 112 | */ 113 | public function setStatusCode($code, $message = null) 114 | { 115 | $this->httpCode = $code; 116 | $this->httpMessage = $message; 117 | } 118 | 119 | public function getStatusCode() 120 | { 121 | return $this->httpCode; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/API/Relation.php: -------------------------------------------------------------------------------- 1 | relation = $relation; 114 | $this->modelManager = $modelManager; 115 | } 116 | 117 | /** 118 | * pass unknown functions down to $relation 119 | * 120 | * @param mixed $name 121 | * @param mixed $arguments 122 | */ 123 | function __call($name, $arguments) 124 | { 125 | return $this->relation->$name($arguments); 126 | } 127 | 128 | /** 129 | * get the table name by passing this along to the underlying model 130 | * @param string $type 131 | * @return 132 | */ 133 | public function getTableName($type = 'plural') 134 | { 135 | $property = $type . 'TableName'; 136 | if ($this->$property == null) { 137 | $this->model = $this->getModel(); 138 | $this->$property = $this->model->getTableName($type); 139 | } 140 | return $this->$property; 141 | } 142 | 143 | /** 144 | * Get the singular/plural model name for a relationship 145 | * 146 | * @param string $type 147 | * @return string 148 | */ 149 | public function getModelName($type = 'plural') 150 | { 151 | $property = $type . 'ModelName'; 152 | if ($this->$property == null) { 153 | $model = $this->getModel(); 154 | $this->$property = $model->getModelName($type); 155 | } 156 | return $this->$property; 157 | } 158 | 159 | /** 160 | * Get the singular/plural model name for a relationship 161 | * 162 | * @return string 163 | */ 164 | public function getPrimaryKeyName() 165 | { 166 | if ($this->primaryKey == null) { 167 | $model = $this->getModel(); 168 | $this->primaryKey = $model->getPrimaryKeyName(); 169 | } 170 | return $this->primaryKey; 171 | } 172 | 173 | /** 174 | * Get the primary model for a relationship 175 | * 176 | * @return string 177 | */ 178 | public function getAlias() 179 | { 180 | if (!isset($this->alias)) { 181 | $options = $this->getOptions(); 182 | if (isset($options['alias'])) { 183 | $this->alias = $options['alias']; 184 | } else { 185 | $this->alias = null; 186 | } 187 | } 188 | 189 | return $this->alias; 190 | } 191 | 192 | /** 193 | * get the name of the parent model (w/o namespace) 194 | * 195 | * @return string or false 196 | */ 197 | public function getParent() 198 | { 199 | $name = $this->relation->getReferencedModel(); 200 | return $name::$parentModel; 201 | } 202 | 203 | /** 204 | * get a list of hasOne tables, similar to getParent but more inclusive 205 | * 206 | * @return string or false 207 | */ 208 | public function getHasOnes() 209 | { 210 | $modelNameSpace = $this->relation->getReferencedModel(); 211 | $list = []; 212 | $relationships = $this->modelManager->getRelations($modelNameSpace); 213 | foreach ($relationships as $relation) { 214 | $refType = $relation->getType(); 215 | if ($refType == 1) { 216 | $list[] = $relation->getReferencedModel(); 217 | } 218 | } 219 | 220 | return $list; 221 | } 222 | 223 | /** 224 | * ez access to the "foreign" model depicted by the relationship 225 | * 226 | * @return \PhalconRest\API\BaseModel 227 | */ 228 | public function getModel() 229 | { 230 | if ($this->model == null) { 231 | $name = $this->relation->getReferencedModel(); 232 | $this->model = new $name(); 233 | } 234 | return $this->model; 235 | } 236 | 237 | /** 238 | * provide explicit access to what would otherwise be a private property of the relationship 239 | * 240 | * @param $model 241 | */ 242 | public function setModel($model) 243 | { 244 | $this->model = $model; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/API/RelationFilter.php: -------------------------------------------------------------------------------- 1 | operator = $operator; 43 | $this->value = $value; 44 | $this->name = $name; 45 | } 46 | } -------------------------------------------------------------------------------- /src/API/SearchHelper.php: -------------------------------------------------------------------------------- 1 | di = $di; 173 | 174 | $this->customParams = $customParams; 175 | $this->parseRequest(); 176 | } 177 | 178 | 179 | /** 180 | * load a supplied with string into the class variable 181 | * @param string $name 182 | */ 183 | public function addEntityWith(string $name) 184 | { 185 | 186 | if (strstr($this->entityWith, $name)) { 187 | // it's already loaded, do nothing 188 | } 189 | 190 | switch ($this->entityWith) { 191 | case 'none': 192 | $this->entityWith = $name; 193 | break; 194 | case 'all': 195 | // do nothing all are loaded anyway 196 | break; 197 | 198 | default: 199 | $this->entityWith .= ',' . $name; 200 | break; 201 | } 202 | 203 | 204 | } 205 | 206 | /** 207 | * return the correct limit based on set order 208 | * supplied, entity, none 209 | * 210 | * @return mixed 211 | */ 212 | public function getLimit() 213 | { 214 | if (!is_null($this->suppliedLimit)) { 215 | return $this->suppliedLimit; 216 | } elseif (!is_null($this->entityLimit)) { 217 | return $this->entityLimit; 218 | } 219 | return false; 220 | } 221 | 222 | /** 223 | * return the correct offset based on set order 224 | * supplied, entity, none 225 | * 226 | * @return mixed 227 | */ 228 | public function getOffset() 229 | { 230 | if (!is_null($this->suppliedOffset)) { 231 | return $this->suppliedOffset; 232 | } elseif (!is_null($this->entityOffset)) { 233 | return $this->entityOffset; 234 | } 235 | return false; 236 | } 237 | 238 | /** 239 | * return the correct sort based on set order 240 | * supplied, entity, none 241 | * 242 | * @param string $format 243 | * return the native string format or a sql version instead? 244 | * @return mixed mixed return false when no sort if found, otherwise a string in native|sql format 245 | */ 246 | public function getSort($format = 'native') 247 | { 248 | if (!is_null($this->suppliedSort)) { 249 | $nativeSort = $this->suppliedSort; 250 | } elseif (!is_null($this->entitySort)) { 251 | $nativeSort = $this->entitySort; 252 | } else { 253 | // no explicit sort supplied, return false 254 | return false; 255 | } 256 | 257 | // convert to sql dialect if asked 258 | if ($format == 'sql') { 259 | // first_name,-last_name 260 | $sortFields = explode(',', $nativeSort); 261 | $parsedSorts = array(); // the final Phalcon friendly sort array 262 | 263 | foreach ($sortFields as $order) { 264 | if (substr($order, 0, 1) == '-') { 265 | $subOrder = substr($order, 1); 266 | if (!empty($subOrder)) { 267 | $parsedSorts[] = $subOrder . " DESC"; 268 | } 269 | } else { 270 | $parsedSorts[] = $order; 271 | } 272 | } 273 | $nativeSort = implode(',', $parsedSorts); 274 | } 275 | 276 | return $nativeSort; 277 | } 278 | 279 | /** 280 | * return the correct set of related tables to include 281 | * 282 | * client: default, all, none, csv 283 | * entity: block, all, none, csv 284 | * 285 | * client: all, none, csv 286 | * 287 | * @return string 288 | */ 289 | public function getWith() 290 | { 291 | // protect entity if it demands that nothing be sideloaded 292 | if ($this->entityWith == 'block') { 293 | return 'none'; 294 | } 295 | 296 | // put entity in charge if client defers 297 | if ($this->suppliedWith == 'default') { 298 | return $this->entityWith; 299 | } 300 | 301 | // put client in charge if entity defers 302 | if ($this->entityWith == 'none') { 303 | return $this->suppliedWith; 304 | } 305 | 306 | // check for all 307 | if ($this->entityWith == 'all' or $this->suppliedWith == 'all') { 308 | return 'all'; 309 | } 310 | 311 | // return merged result if nothing else was detected 312 | return $this->entityWith . ',' . $this->suppliedWith; 313 | } 314 | 315 | /** 316 | * entity search fields are always applied and should not be modified by suppliedSearchFields 317 | * 318 | * @return multitype:NULL |boolean 319 | */ 320 | public function getSearchFields() 321 | { 322 | $searchFields = array(); 323 | 324 | // return false if nothing is specified 325 | if (!isset($this->entitySearchFields) and !isset($this->suppliedSearchFields)) { 326 | return false; 327 | } 328 | 329 | // list supplied first so it will get overwritten by entity 330 | $sources = array( 331 | 'suppliedSearchFields', 332 | 'entitySearchFields' 333 | ); 334 | 335 | foreach ($sources as $source) { 336 | if (isset($this->$source)) { 337 | foreach ($this->$source as $key => $value) { 338 | $searchFields[$key] = $value; 339 | } 340 | } 341 | } 342 | 343 | return $searchFields; 344 | } 345 | 346 | /** 347 | * Main method for parsing a query string. 348 | * Finds search paramters, partial response fields, limits, and offsets. 349 | * 350 | * @return void 351 | */ 352 | protected function parseRequest() 353 | { 354 | if ($this->customParams === false) { 355 | // pull various supported inputs from post 356 | $request = $this->di->get('request'); 357 | 358 | // only process if it is a get 359 | if ($request->isGet() == false) { 360 | return; 361 | } 362 | } else { 363 | $_REQUEST = $_GET = $this->customParams; 364 | $request = $this->di->get('request'); 365 | } 366 | 367 | // simple stuff first 368 | $with = $request->get('with', "string", null); 369 | $include = $request->get('include', "string", null); 370 | if (!is_null($with)) { 371 | $this->suppliedWith = $with; 372 | } elseif (!is_null($include)) { 373 | $this->suppliedWith = $include; 374 | } 375 | 376 | // load possible sort values in the following order 377 | // be sure to mark this as a paginated result set 378 | if ($request->get('sort', "string", null) != null) { 379 | $this->suppliedSort = $request->get('sort', "string", null); 380 | $this->isPager = true; 381 | } elseif ($request->get('sort_field', "string", null) != null) { 382 | $this->suppliedSort = $request->get('sort_field', "string", null); 383 | $this->isPager = true; 384 | } elseif ($request->get('sortField', "string", null) != null) { 385 | $this->suppliedSort = $request->get('sortField', "string", null); 386 | $this->isPager = true; 387 | } 388 | 389 | // prep for the harder stuff 390 | // ?page=1&perPage=25&orderAscending=false 391 | 392 | // load possible limit values in the following order 393 | // be sure to mark this as a paginated result set 394 | if ($request->get('limit', "string", null) != null) { 395 | $this->suppliedLimit = $request->get('limit', "string", null); 396 | $this->isPager = true; 397 | } elseif ($request->get('per_page', "string", null) != null) { 398 | $this->suppliedLimit = $request->get('per_page', "string", null); 399 | $this->isPager = true; 400 | } elseif ($request->get('perPage', "string", null) != null) { 401 | $this->suppliedLimit = $request->get('perPage', "string", null); 402 | $this->isPager = true; 403 | } 404 | 405 | // look for string that means to show all records 406 | if ($this->isPager) { 407 | if ($this->suppliedLimit == 'all') { 408 | $this->suppliedLimit = 9999999999; 409 | } 410 | } 411 | 412 | // load offset values in the following order 413 | // Notice that a page is treated differently than offset 414 | // $this->offset = ($offset != null) ? $offset : $this->offset; 415 | if ($request->get('offset', "int", null) != null) { 416 | $this->suppliedOffset = $request->get('offset', "int", null); 417 | $this->isPager = true; 418 | } elseif ($request->get('page', "int", null) != null) { 419 | $this->suppliedOffset = ($request->get('page', "int", null) - 1) * $this->suppliedLimit; 420 | $this->isPager = true; 421 | } 422 | 423 | // Check if we pass the count parameter with whatever value 424 | if ($request->has('count')) { 425 | $this->isCount = true; 426 | } 427 | 428 | // http://jsonapi.org/format/#fetching 429 | $this->parseSearchParameters($request); 430 | } 431 | 432 | /** 433 | * will apply the configured search parameters and build an array for phalcon consumption 434 | * 435 | * @return array 436 | */ 437 | public function buildSearchParameters() 438 | { 439 | $search_parameters = array(); 440 | 441 | if ($this->isSearch) { 442 | // format for a search 443 | $approved_search = array(); 444 | foreach ($this->getSearchFields() as $field => $value) { 445 | // if we spot a wild card, convert to LIKE 446 | if (strstr($value, '*')) { 447 | $value = str_replace('*', '%', $value); 448 | $approved_search[] = "$field LIKE '$value'"; 449 | } else { 450 | if (strstr($value, '!')) { 451 | $value = str_replace('!', '%', $value); 452 | $approved_search[] = "$field NOT LIKE '$value'"; 453 | } else { 454 | $approved_search[] = "$field='$value'"; 455 | } 456 | } 457 | } 458 | // implode as specified by phalcon 459 | $search_parameters = array( 460 | implode(' and ', $approved_search) 461 | ); 462 | } 463 | 464 | $limit = $this->getLimit(); 465 | if ($limit) { 466 | $search_parameters['limit'] = $limit; 467 | } 468 | 469 | $offset = $this->getOffset(); 470 | if ($offset) { 471 | $search_parameters['offset'] = $offset; 472 | } 473 | 474 | $sort = $this->getSort('sql'); 475 | if ($sort) { 476 | // first_name,-last_name 477 | $search_parameters['order'] = $sort; 478 | } 479 | 480 | return $search_parameters; 481 | } 482 | 483 | /** 484 | * Parses out the search parameters from a request. 485 | * Will process all URL encoded variable except those on the exception list 486 | * Unparsed, they will look like this: 487 | * (name:Benjamin Franklin,location:Philadelphia) 488 | * subjects&limit=100&offset=9&name=Franklin&location=Philadelphia 489 | * Parsed: 490 | * array('name'=>'Franklin', 'location'=>'Philadelphia') 491 | * 492 | * @param object $request 493 | * Unparsed search string 494 | * @return void 495 | */ 496 | protected function parseSearchParameters($request) 497 | { 498 | $allFields = $request->getQuery(); 499 | 500 | $this->isSearch = true; 501 | 502 | $mapped = []; 503 | 504 | // Split the strings at their colon, set left to key, and right to value. 505 | foreach ($allFields as $key => $value) { 506 | 507 | if (in_array($key, $this->reservedWords)) { 508 | // ignore, it is reserved 509 | } else { 510 | $sanitizedValue = $request->getQuery($key, 'string'); 511 | 512 | // make exception for single quote 513 | // support filtering values like o'brien 514 | $sanitizedValue = str_ireplace("'", "'", $sanitizedValue); 515 | 516 | // sanitize fails for < or <=, even html encoded version 517 | // insert exception for < value 518 | if (strlen($sanitizedValue) == 0 and substr($value, 0, 1) == '<') { 519 | $mapped[$key] = $value; 520 | } else { 521 | $mapped[$key] = $sanitizedValue; 522 | } 523 | } 524 | } 525 | 526 | // save the parsed fields to the class 527 | $this->suppliedSearchFields = $mapped; 528 | } 529 | } 530 | -------------------------------------------------------------------------------- /src/API/SecureController.php: -------------------------------------------------------------------------------- 1 | request->getMethod() == 'OPTIONS' && APPLICATION_ENV != 'production') { 28 | return; 29 | } 30 | 31 | $config = $this->getDI()->get('config'); 32 | $auth = $this->getDI()->get('auth'); 33 | 34 | switch ($config['security']) { 35 | case true: 36 | $token = $this->getAuthToken(); 37 | 38 | // check for a valid session 39 | if ($auth->isLoggedIn($token)) { 40 | // get the security service object 41 | $securityService = $this->getDI()->get('securityService'); 42 | // run security check 43 | $this->securityCheck($securityService); 44 | } else { 45 | throw new HTTPException('Unauthorized, please authenticate first.', 401, [ 46 | 'dev' => 'Must be authenticated to access.', 47 | 'code' => '30945680384502037' 48 | ]); 49 | } 50 | break; 51 | 52 | case false: 53 | // if security is off, then create a fake user profile to trick the api 54 | // todo figure out a way to do this w/o this assumption 55 | // notice the specific requirement to a client application 56 | if ($auth->isLoggedIn('HACKYHACKERSON')) { 57 | // run security check..you did program one in your app right? 58 | $this->securityCheck($this->getDI()->get('securityService')); 59 | } else { 60 | throw new HTTPException('Security False is not loading a valid user.', 401, [ 61 | 'dev' => 'The authenticator isn\'t loading a valid user.', 62 | 'code' => '23749873490704' 63 | ]); 64 | } 65 | break; 66 | 67 | default: 68 | throw new HTTPException('Bad security value supplied', 500, ['code' => '280273409724075']); 69 | break; 70 | } 71 | 72 | // continue after security is worked out 73 | parent::onConstruct(); 74 | 75 | } 76 | 77 | /** 78 | * Tries to get the Authorization Token in this order: 79 | * 1. Header: X-Authorization 80 | * 2. GET "token" 81 | * 3. POST "token" 82 | * @throws HTTPException 401 If token is not found 83 | * @return string 84 | */ 85 | protected function getAuthToken() 86 | { 87 | $token = $this->request->getHeader('X_AUTHORIZATION'); 88 | if (!$token) { 89 | $request = $this->getDI()->get('request'); 90 | $token = $request->getQuery('token') ?: $request->getPost('token'); 91 | } 92 | 93 | $token = trim(str_ireplace('Token:', '', $token)); 94 | if (strlen($token) < 30) { 95 | throw new HTTPException('Bad token supplied', 401, [ 96 | 'dev' => 'Supplied Token: ' . $token, 97 | 'code' => '0273497957' 98 | ]); 99 | } 100 | 101 | return $token; 102 | } 103 | 104 | /** 105 | * This is a method that is to be defined in classes that extend \PhalconRest\API\SecureController 106 | * 107 | * @param object $securityService 108 | * @return boolean 109 | */ 110 | protected function securityCheck($securityService) 111 | { 112 | return true; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/API/SimpleSecureController.php: -------------------------------------------------------------------------------- 1 | request->getMethod() == 'OPTIONS' && APPLICATION_ENV != 'production') { 26 | return; 27 | } 28 | 29 | $config = $this->getDI()->get('config'); 30 | $auth = $this->getDI()->get('auth'); 31 | 32 | switch ($config['security']) { 33 | case true: 34 | $token = $this->getAuthToken(); 35 | 36 | // check for a valid session 37 | if ($auth->isLoggedIn($token)) { 38 | // continue 39 | } else { 40 | throw new HTTPException('Unauthorized, please authenticate first.', 401, [ 41 | 'dev' => 'Must be authenticated to access.', 42 | 'code' => '30945680384502037' 43 | ]); 44 | } 45 | break; 46 | 47 | case false: 48 | // if security is off, then create a fake user profile to trick the api 49 | // todo figure out a way to do this w/o this assumption 50 | // notice the specific requirement to a client application 51 | if ($auth->isLoggedIn('HACKYHACKERSON')) { 52 | // continue 53 | } else { 54 | throw new HTTPException('Security False is not loading a valid user.', 401, [ 55 | 'dev' => 'The authenticator isn\'t loading a valid user.', 56 | 'code' => '23749873490704' 57 | ]); 58 | } 59 | break; 60 | 61 | default: 62 | throw new HTTPException('Bad security value supplied', 500, ['code' => '280273409724075']); 63 | break; 64 | } 65 | 66 | // continue after security is worked out 67 | parent::onConstruct(); 68 | } 69 | 70 | /** 71 | * Tries to get the Authorization Token in this order: 72 | * 1. Header: X-Authorization 73 | * 2. GET "token" 74 | * 3. POST "token" 75 | * @throws HTTPException 401 If token is not found 76 | * @return string 77 | */ 78 | protected function getAuthToken() 79 | { 80 | $token = $this->request->getHeader('X_AUTHORIZATION'); 81 | if (!$token) { 82 | $request = $this->getDI()->get('request'); 83 | $token = $request->getQuery('token') ?: $request->getPost('token'); 84 | } 85 | 86 | $token = trim(str_ireplace('Token:', '', $token)); 87 | if (strlen($token) < 30) { 88 | throw new HTTPException('Unauthorized access blocked', 401, [ 89 | 'dev' => 'Supplied Token: ' . $token, 90 | 'code' => '98914681681864674' 91 | ]); 92 | } 93 | return $token; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Authentication/AdapterInterface.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 63 | $this->profile = $profile; 64 | } 65 | 66 | /** 67 | * 68 | * (non-PHPdoc) 69 | * 70 | * @see AuthenticatorInterface::isLoggedIn() 71 | * 72 | */ 73 | public function isLoggedIn($token) 74 | { 75 | // ignore blank tokens 76 | if (strlen($token) == 0) { 77 | false; 78 | } 79 | 80 | $this->authenticated = $this->profile->loadProfile("$this->tokenFieldName = '$token'"); 81 | return $this->authenticated; 82 | } 83 | 84 | /** 85 | * (non-PHPdoc) 86 | * 87 | * @see AuthenticatorInterface::logUserOut() 88 | * 89 | */ 90 | public function logUserOut($token) 91 | { 92 | // ignore blank tokens 93 | if (strlen($token) == 0) { 94 | false; 95 | } 96 | 97 | $result = $this->isLoggedIn($token); 98 | $this->beforeLogout($token); 99 | if ($result) { 100 | $result = $this->profile->resetToken(true); 101 | } 102 | $this->afterLogout($token, $result); 103 | return $result; 104 | } 105 | 106 | /** 107 | * run a set of credentials against the adapters internal authenticate function 108 | * will retain a copy of the adapter provided profile 109 | * 110 | * @param string $userName 111 | * @param string $password 112 | * @return boolean 113 | */ 114 | public function authenticate($userName, $password) 115 | { 116 | $this->beforeLogin($userName, $password); 117 | $result = $this->adapter->authenticate($userName, $password); 118 | if ($result) { 119 | $this->profile->loadProfile("$this->userNameFieldName = '$userName'"); 120 | $this->profile->resetToken(); 121 | } 122 | $this->afterLogin($userName, $password, $result); 123 | return $result; 124 | } 125 | 126 | /** 127 | * will return a valid userProfile object for a pre-loaded profile 128 | * (non-PHPdoc) 129 | * 130 | * @see \PhalconRest\Authentication\AuthenticatorInterface::getProfile() 131 | */ 132 | function getProfile() 133 | { 134 | return $this->profile; 135 | } 136 | 137 | /** 138 | * hook to call before a login attempt 139 | * 140 | * @param string $userName 141 | * @param string $password 142 | */ 143 | public function beforeLogin($userName, $password) 144 | { 145 | } 146 | 147 | /** 148 | * hook to call after a login attempt 149 | * 150 | * @param string $userName 151 | * @param string $password 152 | */ 153 | public function afterLogin($userName, $password, $result) 154 | { 155 | } 156 | 157 | /** 158 | * hook to call before a logout attempt 159 | * 160 | * @param string $userName 161 | * @param string $password 162 | */ 163 | public function beforeLogout($token) 164 | { 165 | } 166 | 167 | /** 168 | * hook to call after a logout attempt 169 | * 170 | * @param string $userName 171 | * @param string $password 172 | */ 173 | public function afterLogout($token, $result) 174 | { 175 | } 176 | } -------------------------------------------------------------------------------- /src/Authentication/AuthenticatorInterface.php: -------------------------------------------------------------------------------- 1 | add(new \DateInterval('PT1H')); 59 | return $date->format('Y-m-d H:i:s'); 60 | } 61 | 62 | /** 63 | * load a full userProfile object based on a provided token 64 | * 65 | * @param array $search $key=>$value pairs 66 | * @return boolean Was profile loaded? 67 | */ 68 | public function loadProfile($search) 69 | { 70 | // replace me with application specific logic 71 | return true; 72 | } 73 | 74 | /** 75 | * persist the profile to local storage, ie. session, database, memcache etc 76 | * 77 | * @return boolean 78 | */ 79 | public function save() 80 | { 81 | // replace with application specific logic 82 | return true; 83 | } 84 | 85 | /** 86 | * take an array of profile data and return a fully compatible result set 87 | * 88 | * @param array $profile 89 | * @return \PhalconRest\Result\Result 90 | */ 91 | public function getResult($profile = []) 92 | { 93 | 94 | $profile['userName'] = $this->userName; 95 | $profile['token'] = $this->token; 96 | 97 | $di = \Phalcon\Di::getDefault(); 98 | // the final result generated by the entity object 99 | $result = $di->get('result', ['profile']); 100 | $result->outputMode = 'single'; 101 | 102 | $id = (isset($profile['id'])) ? $profile['id'] : 1; 103 | $data = $di->get('data', [$id, 'profile', $profile]); 104 | $result->addData($data); 105 | return $result; 106 | } 107 | 108 | 109 | /** 110 | * export all public properties of the user profile in a simple array 111 | * occasionally useful when processing login requests 112 | * 113 | * @return array 114 | */ 115 | public function toArray() 116 | { 117 | $fields = (array)$this; 118 | //this filters out any private or protected properties. object>array cast adds a null byte before those 119 | return array_filter($fields, function ($key) { 120 | return $key[0] !== "\0"; 121 | }, ARRAY_FILTER_USE_KEY); 122 | } 123 | 124 | } -------------------------------------------------------------------------------- /src/Exception/DatabaseException.php: -------------------------------------------------------------------------------- 1 | line = $errorList['line'] ?? ''; 84 | $this->file = $errorList['file'] ?? ''; 85 | $this->stack = $errorList['stack'] ?? ''; 86 | $this->context = $errorList['context'] ?? ''; 87 | $this->more = $errorList['more'] ?? ''; 88 | $this->title = $errorList['title'] ?? 'No Title Supplied'; 89 | $this->code = $errorList['code'] ?? 'XX'; 90 | $this->dev = $errorList['dev'] ?? DI::getDefault()->getMessageBag()->getString(); 91 | } 92 | } -------------------------------------------------------------------------------- /src/Exception/HTTPException.php: -------------------------------------------------------------------------------- 1 | errorStore 11 | * 12 | * @author jjenkins 13 | * 14 | */ 15 | class HTTPException extends \Exception 16 | { 17 | 18 | /** 19 | * store a copy of the DI 20 | */ 21 | private $di; 22 | 23 | /** 24 | * hold a valid errorStore object 25 | * 26 | * @var \PhalconRest\Exception\ErrorStore 27 | */ 28 | protected $errorStore; 29 | 30 | /** 31 | * @param string $title required user friendly message to return to the requestor 32 | * @param int $code required HTTP response code 33 | * @param array $errorList list of optional properties to set on the error object 34 | * @param \Throwable $previous previous exception, if any 35 | */ 36 | public function __construct($title, $code, $errorList = [], \Throwable $previous = null) 37 | { 38 | //attaching local code to Exception message in case it's catch somewhere else 39 | $localCode = isset($errorList['code']) ? $errorList['code'] . '/' . $code : $code; 40 | 41 | parent::__construct("[$localCode] $title", $code, $previous); 42 | 43 | // store general error data 44 | $this->errorStore = new ErrorStore($errorList); 45 | $this->errorStore->title = $title; 46 | $this->errorStore->code = $localCode; 47 | 48 | $this->di = \Phalcon\Di::getDefault(); 49 | } 50 | 51 | /** 52 | * Calls out {@link Output::sendError()} with the appropriate values. 53 | */ 54 | public function send() 55 | { 56 | $output = new Output(); 57 | $output->setStatusCode($this->code, $this->getResponseDescription($this->code)); 58 | 59 | //push errorStore into $result object for proper handling 60 | $result = $this->di->get('result', []); 61 | $result->addError($this->errorStore); 62 | $output->send($result); 63 | } 64 | 65 | /** 66 | * @see https://developer.yahoo.com/social/rest_api_guide/http-response-codes.html 67 | * @param int $code 68 | * @return string 69 | */ 70 | protected function getResponseDescription(int $code): string 71 | { 72 | $codes = [ 73 | // Informational 1xx 74 | 100 => 'Continue', 75 | 101 => 'Switching Protocols', 76 | 77 | // Success 2xx 78 | 200 => 'OK', 79 | 201 => 'Created', 80 | 202 => 'Accepted', 81 | 203 => 'Non-Authoritative Information', 82 | 204 => 'No Content', 83 | 205 => 'Reset Content', 84 | 206 => 'Partial Content', 85 | 86 | // Redirection 3xx 87 | 300 => 'Multiple Choices', 88 | 301 => 'Moved Permanently', 89 | 302 => 'Found', // 1.1 90 | 303 => 'See Other', 91 | 304 => 'Not Modified', 92 | 305 => 'Use Proxy', 93 | 94 | // 306 is deprecated but reserved 95 | 307 => 'Temporary Redirect', 96 | 97 | // Client Error 4xx 98 | 400 => 'Bad Request', 99 | 401 => 'Unauthorized', 100 | 402 => 'Payment Required', 101 | 403 => 'Forbidden', 102 | 404 => 'Not Found', 103 | 405 => 'Method Not Allowed', 104 | 406 => 'Not Acceptable', 105 | 407 => 'Proxy Authentication Required', 106 | 408 => 'Request Timeout', 107 | 409 => 'Conflict', 108 | 410 => 'Gone', 109 | 411 => 'Length Required', 110 | 412 => 'Precondition Failed', 111 | 413 => 'Request Entity Too Large', 112 | 414 => 'Request-URI Too Long', 113 | 415 => 'Unsupported Media Type', 114 | 416 => 'Requested Range Not Satisfiable', 115 | 417 => 'Expectation Failed', 116 | 422 => 'Unprocessable Entity', // Validation returns this one 117 | 118 | // Server Error 5xx 119 | 500 => 'Internal Server Error', 120 | 501 => 'Not Implemented', 121 | 502 => 'Bad Gateway', 122 | 503 => 'Service Unavailable', 123 | 504 => 'Gateway Timeout', 124 | 505 => 'HTTP Version Not Supported', 125 | 509 => 'Bandwidth Limit Exceeded' 126 | ]; 127 | 128 | return $codes[$code] ?? 'Unknown Status Code'; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Exception/ValidationException.php: -------------------------------------------------------------------------------- 1 | errorStore 11 | * this version also populates a list of one or more validation messages 12 | * 13 | * @author jjenkins 14 | */ 15 | class ValidationException extends HTTPException 16 | { 17 | 18 | /** 19 | * Important: ValidationException will accept a list of validation objects or a simple key=>value list in the 3rd param 20 | * 21 | * @param string $title the basic error message 22 | * @param array $errorList key=>value pairs for properties of ErrorStore 23 | * @param array $validationList list of phalcon validation objects or key=>value pairs to be converted into validation objects 24 | * @param \Throwable $previous previous exception, if any 25 | */ 26 | public function __construct($title, $errorList, $validationList, \Throwable $previous = null) 27 | { 28 | parent::__construct($title, 422, $errorList, $previous); 29 | 30 | $mergedValidations = []; 31 | foreach ($validationList as $key => $validation) { 32 | // process simple key pair or assume a validation object 33 | $mergedValidations[] = is_string($validation) ? new Message($validation, $key, 34 | 'InvalidValue') : $validation; 35 | } 36 | 37 | $this->errorStore->validationList = $mergedValidations; 38 | } 39 | 40 | /** 41 | * Gives back the list of error messages. 42 | * @return \Phalcon\Mvc\Model\Message[] 43 | */ 44 | public function getErrors() 45 | { 46 | return $this->errorStore->validationList; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Mvc/AtomicCollection.php: -------------------------------------------------------------------------------- 1 | post($routePattern, 'atomicMethod', $handler); 19 | return $this; 20 | } 21 | 22 | public function atomicPut($routePattern, $handler) { 23 | $this->put($routePattern, 'atomicMethod', $handler); 24 | return $this; 25 | } 26 | 27 | public function atomicDelete($routePattern, $handler) { 28 | $this->delete($routePattern, 'atomicMethod', $handler); 29 | return $this; 30 | } 31 | 32 | public function atomicPatch($routePattern, $handler) { 33 | $this->patch($routePattern, 'atomicMethod', $handler); 34 | return $this; 35 | } 36 | } -------------------------------------------------------------------------------- /src/Query/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | setDI($di); 44 | 45 | // the primary model associated with with entity 46 | $this->model = $model; 47 | 48 | $this->entity = $entity; 49 | 50 | // a searchHelper, needed anytime we load an entity 51 | $this->searchHelper = $searchHelper; 52 | 53 | } 54 | 55 | 56 | /** 57 | * build a PHQL based query to be executed by the runSearch method 58 | * broken up into helpers so extending this function duplicates less code 59 | * 60 | * @param boolean $count should we only gather a count of the query? 61 | * @return mixed 62 | * @throws HTTPException 63 | */ 64 | public function build($count = false) 65 | { 66 | $modelNameSpace = $this->model->getModelNameSpace(); 67 | $mm = $this->getDI()->get('modelsManager'); 68 | $query = $mm->createBuilder()->from($modelNameSpace); 69 | 70 | // $columns = $this->model->getAllowedColumns(true); 71 | $columns = array( 72 | "$modelNameSpace.*" 73 | ); 74 | 75 | // process hasOne Joins 76 | $this->queryJoinHelper($query); 77 | $this->querySearchHelper($query); 78 | $this->querySortHelper($query); 79 | 80 | if ($count) { 81 | $query->columns('count(*) as count'); 82 | } else { 83 | // preserve any columns added through joins 84 | $existingColumns = $query->getColumns(); 85 | $allColumns = array_merge($columns, $existingColumns); 86 | $query->columns($allColumns); 87 | } 88 | // skip limit if returning a count 89 | if (!$count) { 90 | $this->queryLimitHelper($query); 91 | } 92 | 93 | // todo build fields feature into PHQL instead of doing in PHP 94 | return $query; 95 | } 96 | 97 | /** 98 | * help $this->queryBuilder to construct a PHQL object 99 | * apply join conditions and return query object 100 | * 101 | * 102 | * @param BuilderInterface $query 103 | * @return BuilderInterface 104 | */ 105 | public function queryJoinHelper(BuilderInterface $query) 106 | { 107 | $config = $this->getDI()->get('config'); 108 | $modelNameSpace = $config['namespaces']['models']; 109 | 110 | $columns = []; 111 | // join all active hasOne and belongTo instead of just the parent hasOne 112 | foreach ($this->entity->activeRelations as $relation) { 113 | 114 | // be sure to skip any relationships that are marked for custom processing 115 | $relationOptions = $relation->getOptions(); 116 | if (isset($relationOptions) && (array_key_exists('customProcessing', 117 | $relationOptions) && ($relationOptions['customProcessing'] === true)) 118 | ) { 119 | continue; 120 | } 121 | 122 | // refer to alias or model path to prefix each relationship 123 | // prefer alias over model path in case of collisions 124 | $alias = $relation->getAlias(); 125 | $referencedModel = $relation->getReferencedModel(); 126 | if (!$alias) { 127 | $alias = $referencedModel; 128 | } 129 | 130 | $type = $relation->getType(); 131 | switch ($type) { 132 | // structure to always join in belongsTo just in case the query filters by a related field 133 | case Relation::BELONGS_TO: 134 | // create both sides of the join 135 | $left = "[$alias]." . $relation->getReferencedFields(); 136 | $right = $modelNameSpace . $this->model->getModelName() . '.' . $relation->getFields(); 137 | // create and alias join 138 | $query->leftJoin($referencedModel, "$left = $right", $alias); 139 | break; 140 | 141 | case Relation::HAS_ONE: 142 | // create both sides of the join 143 | $left = "[$alias]." . $relation->getReferencedFields(); 144 | $right = $modelNameSpace . $this->model->getModelName() . '.' . $relation->getFields(); 145 | // create and alias join 146 | $query->leftJoin($referencedModel, "$left = $right", $alias); 147 | 148 | // add all parent AND hasOne joins to the column list 149 | $columns[] = "[$alias].*"; 150 | break; 151 | 152 | // stop processing these types of joins with the main query. They might return "n" number of related records 153 | // case Relation::HAS_MANY_THROUGH: 154 | // $alias2 = $alias . '_intermediate'; 155 | // $left1 = $modelNameSpace . $this->model->getModelName() . '.' . $relation->getFields(); 156 | // $right1 = "[$alias2]." . $relation->getIntermediateFields(); 157 | // $query->leftJoin($relation->getIntermediateModel(), "$left1 = $right1", $alias2); 158 | // 159 | // $left2 = "[$alias2]." . $relation->getIntermediateReferencedFields(); 160 | // $right2 = "[$alias]." . $relation->getReferencedFields(); 161 | // $query->leftJoin($referencedModel, "$left2 = $right2", $alias); 162 | // break; 163 | 164 | default: 165 | $this->di->get('logger')->warning("Relationship was ignored during join: {$this->model->getModelName()}.$alias, type #$type"); 166 | } 167 | 168 | // attempt to join in side loaded belongsTo records 169 | // add all parent AND hasOne joins to the column list 170 | if ($type == Relation::BELONGS_TO) { 171 | $columns[] = "[$alias].*"; 172 | } 173 | } 174 | $query->columns($columns); 175 | return $query; 176 | } 177 | 178 | /** 179 | * help $this->queryBuilder to construct a PHQL object 180 | * apply search rules based on the searchHelper conditions and return query 181 | * 182 | * @param BuilderInterface $query 183 | * @return BuilderInterface $query 184 | */ 185 | public function querySearchHelper(BuilderInterface $query) 186 | { 187 | $searchFields = $this->searchHelper->getSearchFields(); 188 | if ($searchFields) { 189 | // pre-process the search fields to see if any of the search names require pre-processing 190 | // mostly just looking for || or type syntax otherwise process as default (and) WHERE clause 191 | foreach ($searchFields as $fieldName => $fieldValue) { 192 | $queryField = new QueryField($fieldName, $fieldValue, $this->model, $this->entity); 193 | if ($queryField->isValid() === true) { 194 | $query = $queryField->addWhereClause($query); 195 | } 196 | } 197 | } 198 | return $query; 199 | } 200 | 201 | 202 | /** 203 | * help $this->queryBuilder to construct a PHQL object 204 | * apply specified limit condition and return query object 205 | * 206 | * @param BuilderInterface $query 207 | * @throws HTTPException 208 | * @return BuilderInterface $query 209 | */ 210 | public function queryLimitHelper(BuilderInterface $query) 211 | { 212 | // only apply limit if we are NOT checking the count 213 | $limit = $this->searchHelper->getLimit(); 214 | $offset = $this->searchHelper->getOffset(); 215 | if ($offset and $limit) { 216 | $query->limit($limit, $offset); 217 | } elseif ($limit) { 218 | $query->limit($limit); 219 | } else { 220 | // can't have an offset w/o an limit 221 | throw new HTTPException("A bad query was attempted.", 500, array( 222 | 'dev' => "Encountered an offset clause w/o a limit which is a no-no.", 223 | 'code' => '894791981' 224 | )); 225 | } 226 | 227 | return $query; 228 | } 229 | 230 | /** 231 | * help $this->queryBuilder to construct a PHQL object 232 | * apply sort params and return query object 233 | * 234 | * @param BuilderInterface $query 235 | * @return BuilderInterface $query 236 | */ 237 | public function querySortHelper(BuilderInterface $query) 238 | { 239 | // process sort 240 | $rawSort = $this->searchHelper->getSort('sql'); 241 | 242 | if ($rawSort != false) { 243 | $preparedSort = []; 244 | $sortFields = explode(',', $rawSort); 245 | 246 | foreach ($sortFields as $sortField) { 247 | // detect the correct name space for sort string 248 | // notice this might be a field name with a sort suffix 249 | $fieldBits = explode(' ', $sortField); 250 | 251 | // used to harmonize string after stripping out suffix 252 | $fieldName = $fieldBits[0]; 253 | 254 | // assign a default value 255 | $suffix = ''; 256 | if (count($fieldBits) > 1) { 257 | // something like DESC/ASC 258 | $suffix = $fieldBits[1]; 259 | } 260 | 261 | // detect the correct table->namespace 262 | $prefix = $this->getTableNameSpace($fieldName); 263 | 264 | // now let's account for table:field entries 265 | $fieldBits = explode(':', $fieldName); 266 | $foo = count($fieldBits); 267 | if (count($fieldBits) > 1) { 268 | $fieldName = $fieldBits[1]; 269 | } else { 270 | $fieldName = $fieldBits[0]; 271 | } 272 | 273 | // put it all together for a working sort 274 | $preparedSort[] = ($prefix ? $prefix . '.' : '') . "[$fieldName]" . $suffix; 275 | } 276 | $query->orderBy(implode(',', $preparedSort)); 277 | } else { 278 | // no sort requested, nothing to do here 279 | return $query; 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/Query/QueryField.php: -------------------------------------------------------------------------------- 1 | setDI($di); 68 | 69 | $this->name = $name; 70 | $this->value = $value; 71 | $this->model = $model; 72 | $this->entity = $entity; 73 | } 74 | 75 | 76 | /** 77 | * internally valid the field, is it going to work for the query builder? 78 | * this might not be used in the future, just a placeholder for now 79 | * @return bool 80 | */ 81 | public function isValid() 82 | { 83 | return true; 84 | } 85 | 86 | /** 87 | * This method looks for the existence of syntax extensions to the api and attempts to 88 | * adjust search input values before subjecting them to the queryBuilder 89 | * 90 | * it should handle cases where the fields are prefixed with a : 91 | * it should also handle cases where a || exists 92 | * first_name||last_name=*jenkins* 93 | * or 94 | * table_name:field_name||table_name:field_name 95 | * 96 | * when an array is requested, will return the following: 97 | * 98 | * ['table'=>$, 'field'=>] 99 | * 100 | * 101 | * @param bool $forceArray 102 | * @return array 103 | */ 104 | public function getName() 105 | { 106 | 107 | $processedNames = []; 108 | // process || syntax 109 | if (strpos($this->name, '||') !== false) { 110 | $rawNames = explode('||', $this->name); 111 | } else { 112 | $rawNames = [$this->name]; 113 | } 114 | 115 | foreach ($rawNames as $name) { 116 | // process : syntax in case a table prefix is supplied 117 | if (strpos($name, ':') !== false) { 118 | $nameBits = explode(':', $name); 119 | $processedNames[] = ['table' => $nameBits[0], 'field' => $nameBits[1], 'original' => $name]; 120 | } else { 121 | $processedNames[] = ['field' => $name, 'original' => $name]; 122 | } 123 | } 124 | 125 | // all that left is to return what you found 126 | return $processedNames; 127 | } 128 | 129 | /** 130 | * This method looks for the existence of syntax extensions to the api and attempts to 131 | * adjust search input values before subjecting them to the queryBuilder 132 | * 133 | * The 'or' operator || explodes the given parameter on that operator if found 134 | * 135 | * first_name=jim||john 136 | * first_name||last_name=jim 137 | * 138 | * @param bool $forceArray 139 | * @return array|string 140 | */ 141 | public function getValue($forceArray = false) 142 | { 143 | // process || syntax 144 | if (strpos($this->value, '||') !== false) { 145 | return explode('||', $this->value); 146 | } else { 147 | return ($forceArray ? [$this->value] : $this->value); 148 | } 149 | } 150 | 151 | 152 | /** 153 | * This method determines whether the clause should be processed as an 'and' clause or an 'or' clause. 154 | * This is determined based on the results from the \PhalconRest\API\Entity::processSearchFields() method. If that 155 | * method returns a string, we are dealing with an 'and' clause, if not, we are dealing with an 'or' clause. 156 | * 157 | * @param 158 | * string or array $processedFieldName 159 | * @param 160 | * string or array $processedFieldValue 161 | * @return string 162 | */ 163 | protected function processSearchFieldQueryType() 164 | { 165 | // prep the fields 166 | $processedFieldName = $this->getName(); 167 | $processedFieldValue = $this->getValue(); 168 | // set a default value 169 | $result = 'and'; 170 | 171 | if (count($processedFieldName) > 1 || is_array($processedFieldValue)) { 172 | $result = 'or'; 173 | } 174 | 175 | return $result; 176 | } 177 | 178 | 179 | /** 180 | * for a given value, figure out what type of operator should be used 181 | * 182 | * supported operators are 183 | * 184 | * presense of >, <=, >=, <, !=, <> means to use them instead of the default 185 | * presense of % means use LIKE operator 186 | * = is the default operator 187 | * 188 | * This is determined by the presence of the SQL wildcard character in the fieldValue string 189 | * 190 | * @param string $fieldValue 191 | * @return string 192 | */ 193 | protected function determineWhereOperator(string $fieldValue) 194 | { 195 | $defaultOperator = '='; 196 | 197 | // process wildcards at start and end 198 | $firstChar = substr($fieldValue, 0, 1); 199 | $lastChar = substr($fieldValue, -1, 1); 200 | if (($firstChar == "*") || ($lastChar == "*")) { 201 | return 'LIKE'; 202 | } 203 | if (($firstChar == "!") && ($lastChar == "!")) { 204 | return 'NOT LIKE'; 205 | } 206 | if (($firstChar == "~")) { 207 | return 'BETWEEN'; 208 | } 209 | 210 | 211 | if (strtoupper($fieldValue) === 'NULL') { 212 | return 'IS NULL'; 213 | } 214 | 215 | if (strtoupper($fieldValue) === '!NULL') { 216 | return 'IS NOT NULL'; 217 | } 218 | 219 | // process supported comparision operators 220 | $doubleCharacter = substr($fieldValue, 0, 2); 221 | // notice how multi character operators are processed first 222 | $supportedComparisonOperators = [ 223 | '<=', 224 | '>=', 225 | '<>', 226 | '!=' 227 | ]; 228 | foreach ($supportedComparisonOperators as $operator) { 229 | if ($doubleCharacter === $operator) { 230 | return $doubleCharacter; 231 | } 232 | } 233 | 234 | // if nothing else was detected, process single character comparisons 235 | $supportedComparisonOperators = [ 236 | '>', 237 | '<' 238 | ]; 239 | foreach ($supportedComparisonOperators as $operator) { 240 | if ($firstChar === $operator) { 241 | return $firstChar; 242 | } 243 | } 244 | 245 | // nothing special detected, return the standard operator 246 | return $defaultOperator; 247 | } 248 | 249 | /** 250 | * An additional parse function to deal with advanced searches where a comparision operator is supplied 251 | * Given a fieldValue and operator, filter out the operator from the value 252 | * ie. 253 | * search for the wildcard character and replace with an SQL specific wildcard 254 | * 255 | * 256 | * @param string $fieldValue - a search string 257 | * @param string $operator comparision value 258 | * @return mixed 259 | * @throws HTTPException 260 | */ 261 | protected function processFieldValue($fieldValue, $operator = '=') 262 | { 263 | switch ($operator) { 264 | case '>': 265 | case '<': 266 | return substr($fieldValue, 1); 267 | break; 268 | 269 | case '>=': 270 | case '<=': 271 | case '<>': 272 | case '!=': 273 | return substr($fieldValue, 2); 274 | break; 275 | 276 | case 'LIKE': 277 | case 'NOT LIKE': 278 | // process possible wild cards 279 | $firstChar = substr($fieldValue, 0, 1); 280 | $lastChar = substr($fieldValue, -1, 1); 281 | 282 | // process wildcards 283 | if ($firstChar == "*") { 284 | $fieldValue = substr_replace($fieldValue, "%", 0, 1); 285 | } 286 | if ($lastChar == "*") { 287 | $fieldValue = substr_replace($fieldValue, "%", -1, 1); 288 | } 289 | if ($firstChar == "!") { 290 | $fieldValue = substr_replace($fieldValue, "%", 0, 1); 291 | } 292 | if ($lastChar == "!") { 293 | $fieldValue = substr_replace($fieldValue, "%", -1, 1); 294 | } 295 | return $fieldValue; 296 | break; 297 | case 'BETWEEN': 298 | $parts = explode("~", $fieldValue); 299 | if (count($parts) != 3) { 300 | throw new HTTPException("A bad filter was attempted.", 500, [ 301 | 'dev' => "Encountered a between filter without the correct values, please send ~value1~value2", 302 | 'code' => '975149008326' 303 | ]); 304 | } 305 | $fields[] = $parts[1]; 306 | $fields[] = $parts[2]; 307 | return $fields; 308 | break; 309 | 310 | default: 311 | return $fieldValue; 312 | break; 313 | } 314 | } 315 | 316 | 317 | /** 318 | * when handed a query object, add the derived WHERE clause to the object 319 | * this is derived from the QueryFields internal state 320 | * 321 | * @param Builder $query 322 | * @return mixed|Builder 323 | * @throws HTTPException 324 | */ 325 | public function addWhereClause(Builder $query): Builder 326 | { 327 | 328 | switch ($this->processSearchFieldQueryType()) { 329 | case 'and': 330 | return $this->parseAdd($query); 331 | break; 332 | 333 | case 'or': 334 | return $this->parseOr($query); 335 | break; 336 | } 337 | return $query; 338 | } 339 | 340 | 341 | /** 342 | * compile the correct WHERE clause and add it to the supplied Query object 343 | * this function expects to be called when BOTH the name and value properties are strings 344 | * otherwise, a bug will result! 345 | * 346 | * @param Builder $query 347 | * @return Builder 348 | * @throws HTTPException 349 | */ 350 | private function parseAdd(Builder $query): Builder 351 | { 352 | 353 | $operator = $this->determineWhereOperator($this->getValue()); 354 | $name = $this->getName(); 355 | $value = $this->getValue(); 356 | 357 | // validate 358 | if (is_array($value) OR count($name) > 1) { 359 | // ERROR 360 | throw new HTTPException("Encountered Array when processing a simple Add request.", 500, [ 361 | 'dev' => "parseAdd is built for simple values, but it was run on multiple values. send this to OR!", 362 | 'code' => '4891319849797' 363 | ]); 364 | } 365 | 366 | // this is a safe request since we ruled out possible alternatives 367 | $prefix = $this->getTableNameSpace($this->name); 368 | // disentangle the table from the field name 369 | 370 | $fieldName = ($prefix ? $prefix . '.' : '') . "[{$name[0]['field']}]"; 371 | $fieldValue = $this->processFieldValue($value, $operator); 372 | 373 | if ($operator === 'BETWEEN') { 374 | // expect newFieldValue to be an array 375 | $query->betweenWhere($fieldName, $fieldValue[0], $fieldValue[1]); 376 | } else { 377 | if ($operator === 'IS NULL' OR $operator === 'IS NOT NULL') { 378 | $query->andWhere("$fieldName $operator"); 379 | } else { 380 | $randomName = 'rand' . rand(1, 1000000); 381 | $query->andWhere("$fieldName $operator :$randomName:", [ 382 | $randomName => $fieldValue 383 | ]); 384 | } 385 | } 386 | 387 | return $query; 388 | } 389 | 390 | /** 391 | * compile the correct WHERE clause and add it to the supplied Query object 392 | * 393 | * @param Builder $query 394 | * @return Builder 395 | * @throws HTTPException 396 | */ 397 | private function parseOr(Builder $query): Builder 398 | { 399 | 400 | $nameArray = $this->getName(); 401 | $valueArray = $this->getValue(true); 402 | 403 | // update to bind params instead of using string concatenation 404 | $queryArr = []; 405 | $valueArr = []; 406 | 407 | $count = 1; 408 | foreach ($nameArray as $name) { 409 | $prefix = $this->getTableNameSpace($name['original']); 410 | $fieldName = ($prefix ? $prefix . '.' : '') . "[{$name['field']}]"; 411 | 412 | foreach ($valueArray as $value) { 413 | $marker = 'marker' . rand(1, 999999); 414 | $operator = $this->determineWhereOperator($value); 415 | $fieldValue = $this->processFieldValue($value, $operator); 416 | 417 | if ($operator === 'BETWEEN') { 418 | $queryArr[] = "$fieldName $operator :{$marker}_1: AND :{$marker}_2:"; 419 | $valueArr[$marker . '_1'] = $fieldValue[0]; 420 | $valueArr[$marker . '_2'] = $fieldValue[1]; 421 | } else { 422 | if ($operator === 'IS NULL' || $operator === 'IS NOT NULL') { 423 | $queryArr[] = "$fieldName $operator"; 424 | } else { 425 | $queryArr[] = "$fieldName $operator :$marker:"; 426 | $valueArr[$marker] = $fieldValue; 427 | } 428 | } 429 | 430 | $count++; 431 | } 432 | } 433 | $sql = implode(' OR ', $queryArr); 434 | $query->andWhere($sql, $valueArr); 435 | 436 | return $query; 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/Request/Adapters/ActiveModel.php: -------------------------------------------------------------------------------- 1 | underscore($name); 32 | 33 | // $raw = $this->getRawBody(); 34 | $json = $this->getJsonRawBody(); 35 | if (is_object($json)) { 36 | if ($name) { 37 | if (isset($json->$name)) { 38 | $request = $json->$name; 39 | } else { 40 | // expected name not found 41 | throw new HTTPException('Could not find expected json data.', 500, [ 42 | 'dev' => json_encode($json), 43 | 'code' => '4684646464684' 44 | ]); 45 | } 46 | } else { 47 | // return the entire result set 48 | $request = $json; 49 | } 50 | } else { 51 | // invalid json detected 52 | return null; 53 | } 54 | 55 | // give convert a chance to run 56 | return $this->convertCase($request); 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /src/Request/Adapters/JsonApi.php: -------------------------------------------------------------------------------- 1 | getRawBody(); 28 | $json = $this->getJsonRawBody(); 29 | 30 | $request = null; 31 | if (is_object($json)) { 32 | // return the entire result set 33 | $request = $json->data; 34 | } else { 35 | // invalid json detected 36 | return null; //todo: throw an error instead? 37 | } 38 | // give convert a chance to run 39 | $request = $this->convertCase($request); 40 | return $this->mungeData($request, $model); 41 | } 42 | 43 | 44 | /** 45 | * will disentangle the mess JSON API submits down to something our API can work with 46 | * 47 | * @param $post 48 | * @param BaseModel $model 49 | * @return \stdClass 50 | * @throws HTTPException 51 | */ 52 | 53 | public function mungeData($post, BaseModel $model): \stdClass 54 | { 55 | // munge a bit so it works for internal data handling 56 | if (!isset($post->attributes)) { 57 | // error here, all posts require an attributes tag 58 | throw new HTTPException('The API received a malformed request', 400, [ 59 | 'dev' => 'Bad or incomplete attributes property submitted to the API', 60 | 'code' => '894168146168168168161' 61 | ]); 62 | } else { 63 | $data = $post->attributes; 64 | } 65 | 66 | if (isset($post->id)) { 67 | $data->id = $post->id; 68 | } 69 | 70 | // pull out relationships and convert to simple FKs 71 | if (isset($post->relationships)) { 72 | // go through model relationships and look for foreign keys 73 | $modelRelations = $model->getRelations(); 74 | foreach ($modelRelations as $relation) { 75 | switch ($relation->getType()) { 76 | case PhalconRelation::HAS_ONE: 77 | case PhalconRelation::BELONGS_TO: 78 | // pull from singular 79 | $name = $relation->getTableName('singular'); 80 | if (isset($post->relationships->$name)) { 81 | $fk = $relation->getFields(); 82 | if (isset($post->relationships->$name->data->id)) { 83 | $data->$fk = $post->relationships->$name->data->id; 84 | } else { 85 | // A bad or incomplete relationship record was submitted 86 | // this isn't always an error, it might be that an empty relationship was submitted 87 | } 88 | } 89 | break; 90 | 91 | case PhalconRelation::HAS_MANY: 92 | //TODO: pull plural? 93 | break; 94 | default: 95 | break; 96 | } 97 | } 98 | } 99 | 100 | return $data; 101 | } 102 | 103 | } -------------------------------------------------------------------------------- /src/Request/Request.php: -------------------------------------------------------------------------------- 1 | defaultCaseFormat != false) { 52 | return $this->convertCase($request); 53 | } else { 54 | return $request; 55 | } 56 | } 57 | 58 | /** 59 | * extend to hook up possible case conversion 60 | * 61 | * {@inheritdoc} 62 | */ 63 | public function getPost($name = null, $filters = null, $defaultValue = null, $notAllowEmpty = false, $noRecursive = false) 64 | { 65 | // perform parent function 66 | $request = parent::getPost($name, $filters, $defaultValue); 67 | 68 | // special handling for array requests, for individual inputs return what is request 69 | if (is_array($request) and $this->defaultCaseFormat != false) { 70 | if ($this->getJsonRawBody() == null) { 71 | return $this->convertCase($request); 72 | } else { 73 | return $this->getJsonRawBody(); 74 | } 75 | } else { 76 | return $request; 77 | } 78 | } 79 | 80 | /** 81 | * for a given array of values, convert cases to the defaultCaseFormat 82 | * 83 | * @param array|object $request 84 | * @return array|object 85 | */ 86 | protected function convertCase($request) 87 | { 88 | $inflector = new Inflector(); 89 | switch ($this->defaultCaseFormat) { 90 | // assume camel case and should convert to snake 91 | case "snake": 92 | if (is_object($request)) { 93 | $request = $inflector->objectPropertiesToSnake($request); 94 | } elseif (is_array($request)) { 95 | $request = $inflector->arrayKeysToSnake($request); 96 | } 97 | 98 | break; 99 | 100 | // assume snake and should convert to camel 101 | case "camel": 102 | if (is_object($request)) { 103 | $request = $inflector->objectPropertiesToCamel($request); 104 | } elseif (is_array($request)) { 105 | $request = $inflector->arrayKeysToCamel($request); 106 | } 107 | break; 108 | default: 109 | break; 110 | } 111 | return $request; 112 | } 113 | 114 | 115 | /** 116 | * a simple function to extract a property's value from the JSON post 117 | * if no match is found, then return null 118 | * 119 | * @param $name 120 | * @param $filter - option to sanitize for a specify type of value 121 | * @throws HTTPException 122 | * @return null 123 | */ 124 | public function getJsonProperty(string $name, $filter = 'string') 125 | { 126 | // manually filter since we are not going through getPost/Put 127 | $filterService = new Filter(); 128 | 129 | $json = $this->getJsonRawBody(); 130 | if (is_object($json)) { 131 | if (isset($json->$name)) { 132 | return $filterService->sanitize($json->$name, $filter); 133 | } else { 134 | // found valid json, but no matching property found 135 | return null; 136 | } 137 | } else { 138 | // expected name not found 139 | throw new HTTPException('Could not find expected json data.', 500, [ 140 | 'dev' => json_encode($json), 141 | 'code' => '894984616161681468764' 142 | ]); 143 | } 144 | } 145 | } -------------------------------------------------------------------------------- /src/Result/Adapters/ActiveModel/Data.php: -------------------------------------------------------------------------------- 1 | di->get('config'); 20 | $formatTo = $config['application']['propertyFormatTo']; 21 | 22 | if ($formatTo == 'none') { 23 | $result = $this->attributes; 24 | } else { 25 | $inflector = $this->di->get('inflector'); 26 | $result = []; 27 | foreach ($this->attributes as $key => $value) { 28 | $result[$inflector->normalize($key, $formatTo)] = $value; 29 | } 30 | } 31 | 32 | // TODO fix this hack 33 | $result['id'] = $this->getId(); 34 | 35 | if ($this->relationships) { 36 | foreach ($this->relationships as $name => $keys) { 37 | $result[$name] = array_keys($keys); 38 | } 39 | // $result['relationships'] = $this->relationships; 40 | } 41 | 42 | return $result; 43 | } 44 | 45 | /** 46 | * add related record to an existing data object 47 | * this function assumes a list of relations are registered with the result object 48 | * 49 | * this particular version is tailored to support the special needs of active model formatting 50 | * 51 | * store id's in keys to provide built-in duplicate detection 52 | * 53 | * @throws HTTPException 54 | * @param $relationshipName string the singular/plural to match the defined relationship 55 | * @param $id integer the value this data relates to 56 | * @param bool $type string that maps to the required json api property (seems to always be SINGULAR_ids) 57 | */ 58 | public function addRelationship($relationshipName, $id, $type = false) 59 | { 60 | $result = $this->di->get('result'); 61 | // this value tells data whether to store related values as array or single object 62 | $relationship = $result->getRelationshipDefinition($relationshipName); 63 | 64 | switch ($relationship->getType()) { 65 | case PhalconRelation::BELONGS_TO: 66 | case PhalconRelation::HAS_ONE: 67 | $relationshipName = $relationship->getTableName('singular') . '_id'; 68 | break; 69 | case PhalconRelation::HAS_MANY_THROUGH: 70 | case PhalconRelation::HAS_MANY: 71 | default: 72 | $relationshipName = $relationship->getTableName('singular') . '_ids'; 73 | break; 74 | } 75 | 76 | if (isset($this->relationships[$relationshipName])) { 77 | $this->relationships[$relationshipName][$id] = null; 78 | } else { 79 | if ($relationship->getType() == PhalconRelation::HAS_ONE OR $relationship->getType() == PhalconRelation::BELONGS_TO) { 80 | $this->relationships[$relationshipName][$id] = null; 81 | } else { 82 | // process for multiple records 83 | $this->relationships[$relationshipName] = []; 84 | $this->relationships[$relationshipName][$id] = null; 85 | } 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/Result/Adapters/ActiveModel/Result.php: -------------------------------------------------------------------------------- 1 | outputMode == self::MODE_ERROR) { 21 | $this->formatFailure($result); 22 | } else { 23 | $this->formatSuccess($result); 24 | } 25 | 26 | // only include if it has valid data 27 | if ($this->meta) { 28 | $result->meta = $this->meta; 29 | } 30 | 31 | return $result; 32 | } 33 | 34 | protected function formatSuccess($result) 35 | { 36 | switch ($this->outputMode) { 37 | case self::MODE_SINGLE: 38 | if (count($this->data)) { 39 | $data = current($this->data); 40 | $type = $data->getType(); 41 | $result->$type = $data; 42 | } 43 | break; 44 | 45 | case self::MODE_MULTIPLE: 46 | // this is used to ensure there is at least a blank array when requesting multiple records 47 | if ($this->type !== false) { 48 | $result->{$this->type} = []; 49 | } 50 | 51 | // push all data records into the result set 52 | foreach ($this->data as $data) { 53 | $type = $data->getType(); 54 | if (!isset($result->$type)) { 55 | $result->$type = []; 56 | } 57 | array_push($result->$type, $data); 58 | } 59 | break; 60 | 61 | case self::MODE_OTHER: 62 | // do nothing for this output mode 63 | break; 64 | 65 | default: 66 | throw new HTTPException('Error generating output. Cannot match output mode with data set.', 500, [ 67 | 'code' => '894684684646846816161' 68 | ]); 69 | } 70 | 71 | // push all includes into the result set. for the purpose of active model, they look just like data records 72 | foreach ($this->included as $type => $data) { 73 | if (!isset($result->$type)) { 74 | $result->$type = array_values($data); 75 | } else { 76 | // not really sure how this would ever be triggered 77 | $result->$type += array_values($data); 78 | } 79 | } 80 | 81 | // include plain non-namespaced data 82 | foreach ($this->plain as $key => $value) { 83 | $result->$key = $value; 84 | } 85 | } 86 | 87 | protected function formatFailure($result) 88 | { 89 | $appConfig = $this->di->get('config')['application']; 90 | $inflector = $this->di->get('inflector'); 91 | 92 | $result->errors = []; 93 | foreach ($this->errors as $error) { 94 | $errorBlock = []; 95 | if ($error->validationList) { 96 | foreach ($error->validationList as $validation) { 97 | if (is_array($validation->getField())) { 98 | $fields = $validation->getField(); 99 | } else { 100 | $fields = [$validation->getField()]; 101 | } 102 | foreach($fields as $fieldName) { 103 | $field = $inflector->normalize($fieldName, $appConfig['propertyFormatTo']); 104 | if (!isset($errorBlock[$field])) { 105 | $errorBlock[$field] = []; 106 | } 107 | $errorBlock[$field][] = $validation->getMessage(); 108 | } 109 | } 110 | } 111 | 112 | $details = [ 113 | 'title' => $error->title, 114 | 'code' => $error->code, 115 | 'details' => $error->more 116 | ]; 117 | 118 | if ($appConfig['debugApp']) { 119 | $meta = array_filter([ //clears up empty keys 120 | 'developer_message' => $error->dev, 121 | 'file' => $error->file, 122 | 'line' => $error->line, 123 | 'stack' => $error->stack, 124 | 'context' => $error->context, 125 | ]); 126 | 127 | if ($meta) { 128 | $details['meta'] = $meta; 129 | } 130 | } 131 | 132 | $result->errors[] = $errorBlock + ['additional_info' => $details]; 133 | } 134 | } 135 | 136 | } -------------------------------------------------------------------------------- /src/Result/Adapters/JsonApi/Data.php: -------------------------------------------------------------------------------- 1 | di->get('config'); 17 | if ($config['application']['propertyFormatTo'] == 'none') { 18 | $attributes = $this->attributes; 19 | } else { 20 | $inflector = $this->di->get('inflector'); 21 | $attributes = []; 22 | foreach ($this->attributes as $key => $value) { 23 | $attributes[$inflector->normalize($key, 24 | $config['application']['propertyFormatTo'])] = $value; 25 | } 26 | } 27 | 28 | $result = [ 29 | 'id' => $this->id, 30 | 'type' => $this->type, 31 | 'attributes' => $attributes 32 | ]; 33 | 34 | if ($this->relationships) { 35 | $result['relationships'] = $this->relationships; 36 | } 37 | 38 | return $result; 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /src/Result/Adapters/JsonApi/Result.php: -------------------------------------------------------------------------------- 1 | outputMode == self::MODE_ERROR) { 21 | $this->formatFailure($result); 22 | } else { 23 | $this->formatSuccess($result); 24 | } 25 | 26 | // include valid, plain non-namespace data 27 | foreach ($this->plain as $key => $value) { 28 | $result->$key = $value; 29 | } 30 | //TODO: better handling of links (self, related, pagination) http://jsonapi.org/format/#document-links 31 | 32 | if ($this->meta) { 33 | $result->meta = $this->meta; 34 | } 35 | 36 | return $result; 37 | } 38 | 39 | /** 40 | * utility class to process extracting intermediate data from the data object 41 | * 42 | * @param $result 43 | * @throws HTTPException 44 | */ 45 | protected function formatSuccess($result) 46 | { 47 | switch ($this->outputMode) { 48 | case self::MODE_SINGLE: 49 | $result->data = array_values($this->data)[0]; 50 | break; 51 | 52 | case self::MODE_MULTIPLE: 53 | $result->data = array_values($this->data); 54 | break; 55 | 56 | case self::MODE_OTHER: 57 | // do nothing for this output mode 58 | break; 59 | 60 | default: 61 | throw new HTTPException('Error generating output. Cannot match output mode with data set.', 500, [ 62 | 'code' => '894684684646846816161' 63 | ]); 64 | } 65 | 66 | // process included records if there's valid entries only 67 | if ($this->data && $this->included) { 68 | $result->included = array_flatten($this->included); 69 | } 70 | } 71 | 72 | /** 73 | * darn there was an error, process the offending code and return to the client 74 | * 75 | * @param $result 76 | */ 77 | protected function formatFailure($result) 78 | { 79 | $appConfig = $this->di->get('config')['application']; 80 | $inflector = $this->di->get('inflector'); 81 | 82 | $result->errors = []; 83 | 84 | //for each errorStore, build one or more error objects 85 | foreach ($this->errors as $error) { 86 | 87 | //if this error includes a list of validation issues, map them into error objects and concat the result 88 | if ($error->validationList) { 89 | $validationErrors = array_map(function (Message $validation) use ($error, $appConfig, $inflector) { 90 | $field = $inflector->normalize($validation->getField(), $appConfig['propertyFormatTo']); 91 | $details = [ 92 | 'code' => $error->code, 93 | 'title' => $error->title, 94 | 'detail' => $validation->getMessage(), 95 | 'source' => ['pointer' => "/data/attributes/$field"], 96 | 'meta' => ['field' => $field] 97 | ]; 98 | if ($appConfig['debugApp'] && $error->dev) { 99 | $details['meta']['developer_message'] = $error->dev; 100 | } 101 | return $details; 102 | }, $error->validationList); 103 | 104 | $result->errors = array_merge($result->errors, $validationErrors); 105 | 106 | //however, if it's a plain error, concat an error object with some additional trace information 107 | } else { 108 | $details = [ 109 | 'title' => $error->title, 110 | 'code' => $error->code, 111 | 'detail' => $error->more, 112 | ]; 113 | 114 | //it doesn't make much sense to add stacktrace info to validation errors, right? 115 | if ($appConfig['debugApp']) { 116 | $meta = array_filter([ //clears up empty keys 117 | 'developer_message' => $error->dev, 118 | 'file' => $error->file, 119 | 'line' => $error->line, 120 | 'stack' => $error->stack, 121 | 'context' => $error->context, 122 | ]); 123 | 124 | if ($meta) { 125 | $details['meta'] = $meta; 126 | } 127 | } 128 | 129 | $result->errors[] = $details; 130 | } 131 | } 132 | } 133 | 134 | } -------------------------------------------------------------------------------- /src/Result/Data.php: -------------------------------------------------------------------------------- 1 | value 30 | * @var array 31 | */ 32 | public $attributes; 33 | 34 | /** 35 | * store a list of related records by their table name (which may also be their type) 36 | * $relationships[TABLENAME] = [ID=>#, TYPE=>'']; 37 | * or 38 | * $relationships[TABLENAME] = [ [ID=>#, TYPE=>''] ]; 39 | * 40 | * @var array|array[] 41 | */ 42 | public $relationships; 43 | 44 | /** 45 | * Data constructor. 46 | * 47 | * @param int $id 48 | * @param string $type 49 | * @param array $attributes 50 | * @param array $relationships 51 | * @param BaseModel|null $model 52 | */ 53 | public function __construct($id, $type, array $attributes = [], array $relationships = [], BaseModel $model = null) 54 | { 55 | $di = \Phalcon\Di::getDefault(); 56 | $this->setDI($di); 57 | 58 | //parse supplied data array and populate object 59 | $this->id = $id; 60 | $this->type = $type; 61 | 62 | // remove this since it is already included in the $id property 63 | unset($attributes['id']); 64 | 65 | if ($model) { 66 | $this->attributes = $this->blockColumns($attributes, $model); 67 | } else { 68 | $this->attributes = $attributes; 69 | } 70 | $this->relationships = $relationships; 71 | } 72 | 73 | /** 74 | * add related record to an existing data object 75 | * this function assumes a list of relations are registered with the result object 76 | * 77 | * @throws HTTPException 78 | * @param $relationshipName string the singular/plural to match the defined relationship 79 | * @param $id integer the value this data relates to 80 | * @param bool $type string that maps to the required json api property (seems to always be the plural version of the resource) 81 | */ 82 | public function addRelationship($relationshipName, $id, $type = false) 83 | { 84 | if (!$type) { 85 | $type = $relationshipName; 86 | } 87 | 88 | $config = $this->di->get('config'); 89 | $inflector = $this->di->get('inflector'); 90 | $result = $this->di->get('result'); 91 | 92 | // this value tells data whether to store related values as array or single object 93 | $relationship = $result->getRelationshipDefinition($relationshipName); 94 | 95 | $relationshipName = $inflector->normalize($relationshipName, $config['application']['propertyFormatTo']); 96 | $type = $inflector->normalize($type, $config['application']['propertyFormatTo']); 97 | 98 | if (isset($this->relationships[$relationshipName])) { 99 | 100 | // surprisingly this can be valid when for example there are multiple belongsTo relationships defined between the same two tables 101 | // ie. document -> created_by AND document -> edited_by AND document -> owned_by 102 | // if ($relationship->getType() == PhalconRelation::HAS_ONE OR $relationship->getType() == PhalconRelation::BELONGS_TO) { 103 | // this is a problem, attempting to load multiple records into a relationship designed for one record 104 | //throw new HTTPException("Attempting to load multiple records into a relationships defined for a single record!", 105 | // 500, array( 106 | // 'code' => '3894646846313546467974974' 107 | // )); 108 | // } 109 | $this->relationships[$relationshipName]['data'][] = ['id' => $id, 'type' => $type]; 110 | } else { 111 | if ($relationship->getType() == PhalconRelation::HAS_ONE OR $relationship->getType() == PhalconRelation::BELONGS_TO) { 112 | $this->relationships[$relationshipName]['data'] = ['id' => $id, 'type' => $type]; 113 | } else { 114 | // process for multiple records 115 | $this->relationships[$relationshipName]['data'] = []; 116 | $this->relationships[$relationshipName]['data'][] = ['id' => $id, 'type' => $type]; 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * simple getters and setters 123 | * @return int|string This is, most of the times, an int, but you can set whatever value you want. Be careful. 124 | */ 125 | public function getId() 126 | { 127 | return $this->id; 128 | } 129 | 130 | /** 131 | * return the data's type property 132 | * @return string 133 | */ 134 | public function getType() 135 | { 136 | return $this->type; 137 | } 138 | 139 | /** 140 | * @param $type 141 | */ 142 | public function setType($type) 143 | { 144 | $this->type = $type; 145 | } 146 | 147 | /** 148 | * return the value for a given field name 149 | * 150 | * @param $name 151 | * @return mixed 152 | * @throws HTTPException 153 | */ 154 | public function getFieldValue($name) 155 | { 156 | if ($name == 'id') { 157 | return $this->id; 158 | } elseif (array_key_exists($name, $this->attributes)) { 159 | return $this->attributes[$name]; 160 | } else { 161 | throw new HTTPException('No matching field name found.', 500, [ 162 | 'dev' => "The API requested a field name that doesn't existing in this data object: $name", 163 | 'code' => '8794793549444' 164 | ]); 165 | } 166 | } 167 | 168 | public function blockColumns($attributes, BaseModel $model) 169 | { 170 | 171 | $blockColumns = $model->getBlockColumns(true); 172 | foreach ($blockColumns as $block) { 173 | unset($attributes[$block]); 174 | } 175 | return $attributes; 176 | 177 | } 178 | 179 | 180 | } -------------------------------------------------------------------------------- /src/Result/Relationship.php: -------------------------------------------------------------------------------- 1 | setDI($di); 28 | 29 | //parse supplied data array and populate object 30 | $this->id = $id; 31 | $this->type = $type; 32 | } 33 | } -------------------------------------------------------------------------------- /src/Result/Result.php: -------------------------------------------------------------------------------- 1 | getType()][$data->getId()] 39 | */ 40 | protected $included = []; 41 | 42 | /** store the list of relationships for a "main" record type 43 | * each data object will refer to this when formatting data objects */ 44 | protected $relationshipRegistry = []; 45 | 46 | /** 47 | * @var string describe what type of result should be returned, should be constrained to one of the MODE_* constants 48 | */ 49 | public $outputMode = self::MODE_OTHER; 50 | 51 | // possible outputModes, useful for some adapter types 52 | const MODE_SINGLE = 'single'; // return a single result 53 | const MODE_MULTIPLE = 'multiple'; // return "n" results 54 | const MODE_ERROR = 'error'; // indicate one or more errors occurred 55 | const MODE_OTHER = 'other'; // return custom data of some type 56 | 57 | /** 58 | * Result constructor. 59 | * @param $type 60 | */ 61 | public function __construct($type = false) 62 | { 63 | $di = \Phalcon\DI::getDefault(); 64 | $this->setDI($di); 65 | $this->type = $type; 66 | } 67 | 68 | 69 | /** 70 | * write or overwrite a definition 71 | * each definition should map to a table name 72 | * we'll massage the name a little bit to cut down on errors 73 | * 74 | * // 1 = hasOne 0 = belongsTo 2 = hasMany 75 | * 76 | * @throws HTTPException 77 | * @param \PhalconRest\Api\Relation|PhalconRelation $relation 78 | */ 79 | public function registerRelationshipDefinitions($relation) 80 | { 81 | //convert to something that makes sense :) 82 | switch ($relation->getType()) { 83 | case PhalconRelation::HAS_ONE: 84 | case PhalconRelation::BELONGS_TO: 85 | case PhalconRelation::HAS_ONE_THROUGH: 86 | $name = $relation->getTableName('singular'); 87 | break; 88 | case PhalconRelation::HAS_MANY: 89 | case PhalconRelation::HAS_MANY_THROUGH: 90 | $name = $relation->getTableName('plural'); 91 | break; 92 | default: 93 | throw new HTTPException('A Bad Relationship Type was supplied!', 500, ['code' => '8948616164789797']); 94 | } 95 | $this->relationshipRegistry[strtolower($name)] = $relation; 96 | } 97 | 98 | /** 99 | * simple getter 100 | * 101 | * @param $name 102 | * @return array 103 | */ 104 | public function getRelationshipDefinition($name) 105 | { 106 | return $this->relationshipRegistry[$name] ?? 'Unknown'; 107 | } 108 | 109 | /** 110 | * push new data objects into the data array 111 | * 112 | * @param Data $newData 113 | */ 114 | public function addData(Data $newData) 115 | { 116 | $this->data[$newData->getId()] = $newData; 117 | } 118 | 119 | /** 120 | * add an error store object to the Result payload 121 | * 122 | * @param ErrorStore $error 123 | */ 124 | public function addError(ErrorStore $error) 125 | { 126 | $this->outputMode = self::MODE_ERROR; 127 | $this->errors[] = $error; 128 | } 129 | 130 | 131 | /** 132 | * add a Data object to include array on result payload 133 | * 134 | * @param \PhalconRest\Result\Data $newData 135 | */ 136 | public function addIncluded(Data $newData) 137 | { 138 | $this->included[$newData->getType()][$newData->getId()] = $newData; 139 | } 140 | 141 | /** 142 | * add a simple key/value pair to the meta object 143 | * 144 | * @todo expand with dot.notation to store nested values 145 | * 146 | * @param $key 147 | * @param $value 148 | */ 149 | public function addMeta($key, $value) 150 | { 151 | $this->meta[$key] = $value; 152 | } 153 | 154 | /** 155 | * for a supplied primary id and related id, create a relationship 156 | * 157 | * @param $id - the id of the primary record 158 | * @param $relationship 159 | * @param $related_id - the primary id of the related record 160 | * @type mixed $type just pass through to data 161 | * @return boolean 162 | */ 163 | public function addRelationship($id, $relationship, $related_id, $type = false) 164 | { 165 | foreach ($this->data as $key => $data) { 166 | if ($data->getId() == $id) { 167 | $this->data[$key]->addRelationship($relationship, $related_id, $type); 168 | return true; 169 | } 170 | } 171 | return false; 172 | } 173 | 174 | /** 175 | * this is a simple set function to store values in an array which is intended to be included in api responses 176 | * 177 | * @todo expand with dot.notation to store nested values 178 | * 179 | * @param $key 180 | * @param $value 181 | */ 182 | public function addPlain(string $key, $value) 183 | { 184 | $this->plain[$key] = $value; 185 | } 186 | 187 | public function addPlains(array $list) 188 | { 189 | foreach ($list as $key => $value) { 190 | $this->addPlain($key, $value); 191 | } 192 | } 193 | 194 | /** 195 | * used this function to perform some final checks on the result set before passing to the adapter 196 | * 197 | * @return mixed 198 | * @throws \Exception 199 | */ 200 | public function outputJSON() 201 | { 202 | if (count($this->data) > 1 && $this->outputMode == self::MODE_SINGLE) { 203 | throw new \Exception('multiple records returned, but output-mode is single?'); 204 | } 205 | return $this->formatJSON(); 206 | } 207 | 208 | /** 209 | * to be implemented by each adapter 210 | * 211 | * @return mixed 212 | */ 213 | abstract protected function formatJSON(); 214 | 215 | 216 | /** 217 | * return the number of records stored in the result object 218 | * 219 | * @return int 220 | */ 221 | public function countResults() 222 | { 223 | return count($this->data); 224 | } 225 | 226 | /** 227 | * kept here to preserve backwards comparability but will be removed at a later date 228 | * 229 | * @deprecated - use addPlain so we use the same function name for other properties 230 | * @param $key 231 | * @param $value 232 | */ 233 | public function setPlain($key, $value) 234 | { 235 | return $this->addPlain($key, $value); 236 | } 237 | 238 | // plain setter 239 | public function setType($type) 240 | { 241 | $this->type = $type; 242 | } 243 | 244 | /** 245 | * for a given relationship and id, return the matching included record 246 | * 247 | * @param $relationshipName 248 | * @param $id 249 | * @return null|mixed 250 | */ 251 | public function getInclude($relationshipName, $id) 252 | { 253 | return $this->included[$relationshipName][$id] ?? null; 254 | } 255 | 256 | /** 257 | * get a copy of all loaded data in the result object 258 | * @return array 259 | */ 260 | public function getData() 261 | { 262 | return $this->data; 263 | } 264 | 265 | /** 266 | * get a copy of all loaded included data in the result object 267 | * @return array 268 | */ 269 | public function getIncluded() 270 | { 271 | return $this->included; 272 | } 273 | 274 | /** 275 | * provides access to the plain array 276 | * 277 | * @param bool $key 278 | * @return array|mixed 279 | */ 280 | public function getPlain($key = false) 281 | { 282 | if ($key) { 283 | if (isset($this->plain[$key])) { 284 | return $this->plain[$key]; 285 | } else { 286 | return null; 287 | } 288 | } else { 289 | return $this->plain; 290 | } 291 | } 292 | 293 | } 294 | -------------------------------------------------------------------------------- /src/Rules/DenyIfRule.php: -------------------------------------------------------------------------------- 1 | crud = $crud; 55 | $this->field = $field; 56 | $this->value = $value; 57 | $this->operator = $operator; 58 | } 59 | 60 | 61 | /** 62 | * for a supplied value, evaluate it against the defined rule set 63 | * 64 | * @param $fieldValue 65 | * @return bool 66 | */ 67 | public function evaluateRule($fieldValue) 68 | { 69 | // what type of variable are we dealing with? 70 | // todo maybe pick and choose when to treat variable as string 71 | $testFunction = "return ('" . $fieldValue . "' $this->operator '$this->value' ) ? true : false ;"; 72 | $result = eval($testFunction); 73 | if (!is_bool($result)) { 74 | throw new HTTPException('DenyIf rule returned invalid result. Must be a boolean', 500, ['code' => '136497941613689198']); 75 | } 76 | return $result; 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /src/Rules/DenyRule.php: -------------------------------------------------------------------------------- 1 | crud = $crud; 34 | } 35 | 36 | 37 | } -------------------------------------------------------------------------------- /src/Rules/FilterRule.php: -------------------------------------------------------------------------------- 1 | searchHelper 16 | * side loading? 17 | * edit/delete? 18 | */ 19 | class FilterRule 20 | { 21 | 22 | /** 23 | * store the supplied field name 24 | * @var null|string 25 | */ 26 | public $name; 27 | 28 | /** 29 | * store the supplied field value 30 | * @var mixed|null|string 31 | */ 32 | public $value; 33 | 34 | /** 35 | * original field value .... for the moment 36 | * @var string 37 | */ 38 | public $field; 39 | 40 | /** 41 | * store the supplied filter operator 42 | * @var null|string 43 | */ 44 | public $operator; 45 | 46 | 47 | /** 48 | * permissions that describe when to apply this rule 49 | * @var int 50 | */ 51 | public $crud = 0; 52 | 53 | /** 54 | * store the name of the table that is referenced in the rule 55 | */ 56 | public $parentTable = FALSE; 57 | 58 | /** 59 | * RelationFilter constructor. 60 | * 61 | * @param string $field 62 | * @param string $value 63 | * @param string $operator 64 | * @param int $crud 65 | */ 66 | function __construct(string $field, string $value, $operator = null, int $crud = READMODE) 67 | { 68 | $this->crud = $crud; 69 | $this->field = $field; 70 | $this->value = $value; 71 | $this->operator = $operator; 72 | 73 | $fieldBits = explode(':', $field); 74 | // parent field search detected, register it 75 | if (count($fieldBits) == 2) { 76 | $this->parentTable = $fieldBits[0]; 77 | } 78 | } 79 | 80 | 81 | } -------------------------------------------------------------------------------- /src/Rules/ModelCallbackRule.php: -------------------------------------------------------------------------------- 1 | crud = $crud; 41 | $this->check = $check; 42 | 43 | // $check(); 44 | 45 | 46 | } 47 | 48 | /** 49 | * for a supplied parameters, run the call back 50 | * do not return a value, let call back handle what it finds and processes 51 | * 52 | * @param \PhalconRest\API\BaseModel $model 53 | * @param $formData 54 | * @return void 55 | */ 56 | public function evaluateCallback(\PhalconRest\API\BaseModel $oldModel, $formData) 57 | { 58 | $check = $this->check; 59 | $check($oldModel, $formData); 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /src/Rules/QueryRule.php: -------------------------------------------------------------------------------- 1 | crud = $crud; 44 | $this->clause = $clause; 45 | } 46 | 47 | 48 | } -------------------------------------------------------------------------------- /src/Rules/Registry.php: -------------------------------------------------------------------------------- 1 | enforceRules) { 47 | $value->enable(); 48 | } else { 49 | $value->disable(); 50 | } 51 | $this->store[$key] = $value; 52 | } 53 | 54 | /** 55 | * check for a rulestore in the registry 56 | * return NULL if no matching name if found 57 | * 58 | * @param $key 59 | * @return mixed|null 60 | */ 61 | public function get($key) 62 | { 63 | if (array_key_exists($key, $this->store)) { 64 | return $this->store[$key]; 65 | } 66 | return null; 67 | } 68 | 69 | /** 70 | * construct a new Store object configured for enforcement 71 | * 72 | * @param $model 73 | * @return Store 74 | */ 75 | public function getNewStore(\PhalconRest\API\BaseModel $model): \PhalconRest\Rules\Store 76 | { 77 | $newStore = new \PhalconRest\Rules\Store($model); 78 | //force store to match registry enforcement setting 79 | if ($this->enforceRules) { 80 | $newStore->enable(); 81 | } else { 82 | $newStore->disable(); 83 | } 84 | return $newStore; 85 | } 86 | 87 | /** 88 | * tell the rule register to deactivate 89 | */ 90 | final public function disable() 91 | { 92 | $this->enforceRules = false; 93 | foreach ($this->store as $store) { 94 | $store->disable(); 95 | } 96 | } 97 | 98 | /** 99 | * tell the rulestore to enforce it's rules 100 | */ 101 | final public function enable() 102 | { 103 | $this->enforceRules = true; 104 | foreach ($this->store as $store) { 105 | $store->enable(); 106 | } 107 | } 108 | 109 | /** 110 | * is the rule store currently enforcing rules? 111 | * @return bool 112 | */ 113 | final public function isEnforcing() 114 | { 115 | return $this->enforceRules; 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/Rules/Store.php: -------------------------------------------------------------------------------- 1 | model = $model; 46 | } 47 | 48 | /** 49 | * get all rules for the relevant action 50 | * If no mode is supplied, all rules are returned 51 | * 52 | * @param int|NULL $crud 53 | * @param string|NULL $type 54 | * @return array 55 | * @throws \ReflectionException 56 | */ 57 | public function getRules(int $crud = NULL, string $type = NULL): array 58 | { 59 | // first pass to match crud filter 60 | if ($crud == NULL) { 61 | $ruleSet = $this->rules; 62 | } else { 63 | $ruleSet = []; 64 | foreach ($this->rules as $rule) { 65 | if ($rule->crud & $crud) { 66 | $ruleSet[] = $rule; 67 | } 68 | } 69 | 70 | } 71 | 72 | // first pass to match crud filter 73 | if ($type == NULL) { 74 | return $ruleSet; 75 | } else { 76 | $filteredRuleSet = []; 77 | foreach ($ruleSet as $rule) { 78 | $reflection = new \ReflectionClass($rule); 79 | $className = $reflection->getShortName(); 80 | if ($className == $type) { 81 | $filteredRuleSet[] = $rule; 82 | } 83 | } 84 | return $filteredRuleSet; 85 | } 86 | } 87 | 88 | 89 | /** 90 | * add a filter rule into the store 91 | * 92 | * @param string $field 93 | * @param string $value 94 | * @param string $operator 95 | * @param int $crud 96 | */ 97 | public function addFilterRule(string $field, string $value, $operator = null, int $crud = READRULES) 98 | { 99 | // if an existing rule is detected, over write 100 | $this->rules[$field] = new FilterRule($field, $value, $operator, $crud); 101 | } 102 | 103 | 104 | /** 105 | * load a query rule into the store 106 | * 107 | * @param string $rule 108 | * @param int $crud 109 | */ 110 | public function addQueryRule(string $rule, int $crud = READRULES) 111 | { 112 | // if an existing rule is detected, over write 113 | $this->rules[rand(1, 99999)] = new QueryRule($rule, $crud); 114 | } 115 | 116 | 117 | /** 118 | * load a block rule into the store 119 | * 120 | * @param string $rule 121 | * @param int $crud 122 | */ 123 | public function addDenyRule(int $crud = READRULES) 124 | { 125 | // if an existing rule is detected, over write 126 | $this->rules[rand(1, 99999)] = new DenyRule($crud); 127 | } 128 | 129 | 130 | /** 131 | * load a modelCallBack rule into the store 132 | * 133 | * @param \Closure $check 134 | * @param int $crud 135 | */ 136 | public function addModelCallbackRule(\Closure $check, $crud = DELETERULES) 137 | { 138 | // if an existing rule is detected, over write 139 | $this->rules[rand(1, 99999)] = new ModelCallbackRule($check, $crud); 140 | } 141 | 142 | /** 143 | * load a deny if rule into the store 144 | * ie. 145 | * $ruleStore->addDenyIfRule('status', Invoices::ST_BILLED, '=', DELETERULES); 146 | * 147 | * @param $field 148 | * @param $value 149 | * @param string $operator 150 | * @param int $crud 151 | */ 152 | public function addDenyIfRule($field, $value, $operator = '==', $crud = DELETERULES) 153 | { 154 | // if an existing rule is detected, over write 155 | $this->rules[rand(1, 99999)] = new DenyIfRule($field, $value, $operator, $crud); 156 | } 157 | 158 | 159 | /** 160 | * clear all rules of particular mode 161 | * If no mode is supplied, all rules are wiped 162 | * 163 | * @param int|NULL $crud 164 | * @return bool 165 | */ 166 | public function clearRules(int $crud = NULL) 167 | { 168 | if ($crud == NULL) { 169 | $this->rules = []; 170 | return true; 171 | } 172 | 173 | $ruleSet = []; 174 | foreach ($this->rules as $rule) { 175 | if (!$rule->crud & $crud) { 176 | $ruleSet[] = $rule; 177 | } 178 | } 179 | $this->rules = $ruleSet; 180 | return true; 181 | } 182 | 183 | /** 184 | * tell the rulestore to ignore it's rules 185 | */ 186 | final public function disable() 187 | { 188 | $this->enforceRules = false; 189 | } 190 | 191 | /** 192 | * tell the rulestore to enforce it's rules 193 | */ 194 | final public function enable() 195 | { 196 | $this->enforceRules = true; 197 | } 198 | 199 | /** 200 | * is the rule store currently enforcing rules? 201 | * @return bool 202 | */ 203 | final public function isEnforcing() 204 | { 205 | return $this->enforceRules; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Traits/TableNamespace.php: -------------------------------------------------------------------------------- 1 | entity->activeRelations as $relation) { 44 | if ($searchBits[0] == $relation->getTableName()) { 45 | 46 | // detect the relationship alias 47 | $alias = $relation->getAlias(); 48 | if (!$alias) { 49 | $alias = $relation->getReferencedModel(); 50 | } 51 | 52 | // set namespace for later pickup 53 | $modelNameSpace = $relation->getReferencedModel(); 54 | $relatedModel = new $modelNameSpace(); 55 | $colMap = $relatedModel->getAllColumns(); 56 | $matchFound = true; 57 | break; 58 | } 59 | } 60 | 61 | // if we made it this far, than a prefix was supplied but it did not match any known hasOne relationship 62 | if ($matchFound == false) { 63 | throw new HTTPException("Unknown table prefix supplied in filter.", 500, array( 64 | 'dev' => "Encountered a table prefix that did not match any known hasOne relationships in the model. 65 | Encountered Search: $fieldName", 66 | 'code' => '891488651361948131461849' 67 | )); 68 | } 69 | } else { 70 | // kept here so subsequent logic can refer to the same field 71 | $fieldName = $searchBits[0]; 72 | 73 | $alias = $this->model->getModelNameSpace(); 74 | $colMap = $this->model->getAllColumns(false); 75 | } 76 | 77 | // return modelNameSpace if the field is detected in the selected model's column map 78 | foreach ($colMap as $field) { 79 | if ($fieldName == $field) { 80 | return ($escape == true ? "[$alias]" : $alias); 81 | } 82 | } 83 | 84 | // still here? try the parent model and prepend the parent model alias if the field is detected in that model's column map 85 | $currentModel = $this->model; 86 | while ($currentModel) { 87 | $parentModelNameSpace = $currentModel->getParentModel(true); 88 | $parentModelName = $currentModel->getParentModel(false); 89 | // if not parent name specified, skip this part 90 | if ($parentModelName) { 91 | $parentModel = new $parentModelNameSpace(); 92 | // loop through all relationships to reference this one by its alias 93 | foreach ($this->entity->activeRelations as $relation) { 94 | $alias = $relation->getAlias(); 95 | // to detect the parent model we'll compare against either the alias or the full model name 96 | if ($parentModelName == $alias || $parentModelName == $relation->getModelName()) { 97 | $colMap = $parentModel->getAllColumns(false); 98 | foreach ($colMap as $field) { 99 | if ($fieldName == $field) { 100 | return ($escape == true ? "[$alias]" : $alias); 101 | } 102 | } 103 | } 104 | } 105 | $currentModel = $parentModel; 106 | } else { 107 | //escape the loop! 108 | $currentModel = false; 109 | } 110 | } 111 | 112 | // if you are here, that means no processing was required? 113 | return false; 114 | } 115 | } -------------------------------------------------------------------------------- /src/bin/base.php: -------------------------------------------------------------------------------- 1 | $value) { 30 | if (is_array($value) && isset($base[$key]) && @is_array($base[$key])) { 31 | $base[$key] = array_merge_recursive_replace($base[$key], $value); 32 | } else { 33 | $base[$key] = $value; 34 | } 35 | } 36 | } 37 | return $base; 38 | } 39 | } 40 | 41 | if (!function_exists('array_deep_key')) { 42 | /** 43 | * Searches for a key deep in a complex array and returns its value. 44 | * Won't complain if any of those keys exists, instead of $array['one']['two']. 45 | * @example array_deep_key_exists('one.two', ['one' => ['two' => 2]]) === true 46 | * @param string $address Dot-separated key names 47 | * @param array $array 48 | * @return null|mixed the value, if found; null otherwise. 49 | */ 50 | function array_deep_key(array $array, $address) 51 | { 52 | $keys = explode('.', $address); 53 | $inside = $array; 54 | foreach ($keys as $key) { 55 | if (array_key_exists($key, $inside)) { 56 | $inside = $inside[$key]; 57 | } else { 58 | return null; 59 | } 60 | } 61 | return $inside; 62 | } 63 | } 64 | 65 | if (!function_exists('array_deep_key_exists')) { 66 | /** 67 | * Searches for a key deep in a complex array. 68 | * @example array_deep_key_exists('one.two', ['one' => ['two' => 2]]) === true 69 | * @param string $path Dot-separated key names 70 | * @param array $array 71 | * @return bool 72 | */ 73 | function array_deep_key_exists($path, array $array): bool 74 | { 75 | $keys = explode('.', $path); 76 | foreach ($keys as $key) { 77 | if (array_key_exists($key, $array)) { 78 | $array = $array[$key]; 79 | } else { 80 | return false; 81 | } 82 | } 83 | return true; 84 | } 85 | } 86 | 87 | if (!function_exists('array_flatten')) { 88 | /** 89 | * Flattens all entries of a matrix into a single, long array of values. 90 | * @param array $matrix A multi-dimensional array 91 | * @param bool $assoc If the given array is associative (and keys should be maintained) or not 92 | * @return array 93 | */ 94 | function array_flatten(array $matrix, $assoc = false): array 95 | { 96 | if (!$assoc) { 97 | $result = []; 98 | array_walk_recursive($matrix, function ($v) use (&$result) { 99 | $result[] = $v; 100 | }); 101 | return $result; 102 | } else { 103 | return is_array(current($matrix)) ? call_user_func_array('array_merge', $matrix) : $matrix; 104 | } 105 | } 106 | } 107 | 108 | if (!function_exists('array_merge_if_not_null')) { 109 | /** 110 | * Merge two arrays that may have different keys. Keep the not null value of the same key 111 | * @param array $arr1 112 | * @param array $arr2 113 | * @return array 114 | */ 115 | function array_merge_if_not_null(array $arr1, array $arr2): array 116 | { 117 | foreach ($arr2 as $key => $val) { 118 | $is_set_and_not_null = isset($arr1[$key]); 119 | if ($val == NULL && $is_set_and_not_null) { 120 | $arr2[$key] = $arr1[$key]; 121 | } 122 | } 123 | return array_merge($arr1, $arr2); 124 | } 125 | } -------------------------------------------------------------------------------- /src/bin/bootstrap/cli.php: -------------------------------------------------------------------------------- 1 | setDI($di); 7 | 8 | if ($config['application']['maintenance']) { 9 | die('Server is down for maintenance.' . PHP_EOL); 10 | } 11 | 12 | /** 13 | * Process the console arguments 14 | */ 15 | if (isset($argv)) { 16 | $arguments = []; 17 | foreach ($argv as $k => $arg) { 18 | if ($k == 1) { 19 | $arguments['task'] = $arg; 20 | } elseif ($k == 2) { 21 | $arguments['action'] = $arg; 22 | } elseif ($k >= 3) { 23 | $arguments['params'][] = $arg; 24 | } 25 | } 26 | } 27 | 28 | // define global constants for the current task and action 29 | define('CURRENT_TASK', (isset($argv[1]) ? $argv[1] : null)); 30 | define('CURRENT_ACTION', (isset($argv[2]) ? $argv[2] : null)); 31 | -------------------------------------------------------------------------------- /src/bin/bootstrap/web.php: -------------------------------------------------------------------------------- 1 | setDI($di); 14 | 15 | /** 16 | * Before every request: 17 | * Returning true in this function resumes normal routing. 18 | * Returning false stops any route from executing. 19 | */ 20 | $app->before(function () use ($app, $di) { 21 | // set standard CORS headers before routing just in case no valid route is found 22 | $config = $di->get('config'); 23 | $app->response->setHeader('Access-Control-Allow-Origin', $config['application']['corsOrigin']); 24 | return true; 25 | }); 26 | 27 | /** 28 | * Mount all of the collections, which makes the routes active. 29 | */ 30 | $T->lap('Loading Routes'); 31 | foreach ($di->get('collections') as $collection) { 32 | $app->mount($collection); 33 | } 34 | $T->lap('Processing Request'); 35 | 36 | /** 37 | * The base route return the list of defined routes for the application. 38 | * This is not strictly REST compliant, but it helps to base API documentation off of. 39 | * By calling this, you can quickly see a list of all routes and their methods. 40 | */ 41 | $app->get('/', function () use ($app, $di) { 42 | $routes = $app->getRouter()->getRoutes(); 43 | $routeDefinitions = [ 44 | 'GET' => [], 45 | 'POST' => [], 46 | 'PUT' => [], 47 | 'PATCH' => [], 48 | 'DELETE' => [], 49 | 'HEAD' => [], 50 | 'OPTIONS' => [] 51 | ]; 52 | foreach ($routes as $route) { 53 | $method = $route->getHttpMethods() ?? 'any'; 54 | $routeDefinitions[$method][] = $route->getPattern(); 55 | } 56 | /** @var Result $result */ 57 | $result = $di->get('result', []); 58 | $result->outputMode = 'other'; 59 | $result->addPlains($routeDefinitions); 60 | return $result; 61 | }); 62 | 63 | /** 64 | * After a route is run, usually when its Controller returns a final value, 65 | * the application runs the following function which actually sends the response to the client. 66 | * 67 | * The default behavior is to send the Controller's returned value to the client as JSON. 68 | * However, by parsing the request query string's 'type' parameter, it is easy to install 69 | * different response type handlers. 70 | */ 71 | $app->after(function () use ($app, $di) { 72 | $method = $app->request->getMethod(); 73 | 74 | /** 75 | * if the system was dealing with an atomic request -> we commit/rollback the transaction 76 | * depending if any problems were encountered or not 77 | */ 78 | $store = $di->get('store'); 79 | $is_atomic = $store->get('transaction_is_atomic'); 80 | if ($is_atomic) { 81 | $rollback_transaction = $store->get('rollback_transaction'); 82 | $db = $di->get('db'); 83 | if ($rollback_transaction) { 84 | $db->rollback(); 85 | } else { 86 | if (intval($app->response->getStatusCode()) >= 400) { 87 | $db->rollback(); 88 | } else { 89 | $db->commit(); 90 | } 91 | } 92 | } 93 | 94 | // Results returned from the route's controller passed to output class for delivery, in case 95 | // something else didn't send it already (useful for plain-text actions) 96 | if (!$app->response->isSent()) { 97 | $output = new \PhalconRest\API\Output(); 98 | 99 | if ($output->getStatusCode() == 200) { //if it's still the default one, let's override in some cases 100 | switch ($method) { 101 | case 'OPTIONS': 102 | $app->response->setStatusCode('200', 'OK'); 103 | $app->response->send(); 104 | return; 105 | 106 | case 'DELETE': //FIXME: usually this is true, but not all DELETE requests would come without content 107 | $app->response->setStatusCode('204', 'No Content'); 108 | $app->response->send(); 109 | return; 110 | 111 | case 'POST': 112 | //FIXME: not all POST requests actually create a new resource. 200 should be used otherwise 113 | $output->setStatusCode('201', 'Created'); 114 | break; 115 | } 116 | } 117 | 118 | $output->send($app->getReturnedValue()); 119 | } 120 | }); 121 | 122 | /** 123 | * The notFound service is the default handler function that runs when no route was matched. 124 | * We set a 404 here unless there's a suppress error codes. 125 | */ 126 | $app->notFound(function () use ($app) { 127 | throw new HTTPException('Not Found.', 404, [ 128 | 'dev' => 'That route was not found on the server.', 129 | 'code' => '4', 130 | 'more' => 'Check route for misspellings.' 131 | ]); 132 | }); -------------------------------------------------------------------------------- /src/bin/config.php: -------------------------------------------------------------------------------- 1 | [ 11 | // routes all requests to a 503 - useful during deploys. Disable after deploy is done 12 | 'maintenance' => false, 13 | 14 | // the path to the main directory holding the application 15 | 'appDir' => APPLICATION_PATH, 16 | // path values to commonly expected api files 17 | "controllersDir" => APPLICATION_PATH . 'controllers/', 18 | "modelsDir" => APPLICATION_PATH . 'models/', 19 | "entitiesDir" => APPLICATION_PATH . 'entities/', 20 | "responsesDir" => APPLICATION_PATH . 'responses/', 21 | "librariesDir" => APPLICATION_PATH . 'libraries/', 22 | 23 | //TODO: is this used? 24 | "exceptionsDir" => APPLICATION_PATH . 'exceptions/', 25 | 26 | // base string after FQDN.../api/v1 or some such 27 | // set to simple default and expect app to override 28 | 'baseUri' => '/', 29 | 'basePath' => '/', 30 | // should the api return additional meta data and enable additional server logging? 31 | 'debugApp' => true, 32 | // where to store cache related files? 33 | 'cacheDir' => '/tmp/', 34 | // where should system temp files go? 35 | 'tempDir' => '/tmp/', 36 | // where should app generated logs be stored? 37 | 'loggingDir' => '/tmp/', 38 | 39 | // how should property names be formatted in results? 40 | // possible values are camel, snake, dash and none 41 | // none means perform no processing on the final output 42 | 'propertyFormatTo' => 'dash', 43 | 44 | // how are your existing database field name formatted? 45 | // possible values are camel, snake, dash 46 | // none means perform no processing on the incoming values 47 | 'propertyFormatFrom' => 'snake', 48 | 49 | // would also accept any FOLDER name in Result\Adapters 50 | 'outputFormat' => 'JsonApi' 51 | ], 52 | 53 | // location to various code sources 54 | 'namespaces' => [ 55 | 'models' => 'PhalconRest\Models\\', 56 | 'controllers' => 'PhalconRest\Controllers\\', 57 | 'libraries' => 'PhalconRest\Libraries\\', 58 | 'entities' => 'PhalconRest\Entities\\', 59 | 60 | // what is the default entity to be loaded when no other is specified? 61 | 'defaultEntity' => '\PhalconRest\API\Entity' 62 | ], 63 | 64 | // is security enabled for this app? 65 | 'security' => true, 66 | 67 | // a series of experimental features 68 | // this section may be left blank 69 | 'feature_flags' => [ 70 | 71 | ] 72 | ]; 73 | 74 | // incorporate the correct environmental config file 75 | // TODO throw error if no file is found? 76 | $overridePath = APPLICATION_PATH . 'config/config.php'; 77 | if (file_exists($overridePath)) { 78 | $config = array_merge_recursive_replace($config, require($overridePath)); 79 | } else { 80 | throw new Exception("Invalid Environmental Config! Could not load the specific config file. Your environment is:" 81 | . APPLICATION_ENV . " but not matching file was found in /app/config/", 23897293759275); 82 | } 83 | 84 | // makes no sense to convert from like to like 85 | // can't throw Exception, not sure why 86 | if ($config['application']['propertyFormatTo'] == $config['application']['propertyFormatFrom']) { 87 | throw new Exception('The API attempted to normalize from one format to the same format', 21345); 88 | } 89 | 90 | return $config; -------------------------------------------------------------------------------- /src/bin/errorHandler.php: -------------------------------------------------------------------------------- 1 | send(); 32 | } else { 33 | // create an errorStore 34 | $errorStore = new PhalconRest\Exception\ErrorStore([ 35 | 'code' => $thrown->getCode(), 36 | 'more' => $thrown->getMessage(), 37 | 'file' => $thrown->getFile(), 38 | 'line' => $thrown->getLine(), 39 | 'title' => 'Unexpected ' . get_class($thrown), 40 | 'stack' => $thrown->getTrace() 41 | ]); 42 | 43 | if ($thrown->getPrevious()) { 44 | $errorStore->context = '[Previous] ' . (string)$thrown->getPrevious(); 45 | } 46 | 47 | // push to result 48 | $result = $di->get('result', []); 49 | $result->addError($errorStore); 50 | 51 | // send to output 52 | $output = new Output; 53 | $output->setStatusCode(500, 'Error processing the request'); 54 | $output->send($result); 55 | } 56 | }); 57 | 58 | 59 | /** 60 | * this is a small function to connect fatal PHP errors to global error handling 61 | */ 62 | register_shutdown_function(function () use ($app, $config, $di) { 63 | $error = error_get_last(); 64 | if ($error) { 65 | // clean any pre-existing error text output to the screen 66 | ob_clean(); 67 | 68 | $errorStore = new \PhalconRest\Exception\ErrorStore([ 69 | 'code' => '8273492734598729347598237', 70 | 'title' => $error['type'] . ' - ' . errorTypeStr($error['type']), 71 | 'more' => $error['message'], 72 | 'line' => $error['line'], 73 | 'file' => $error['file'] 74 | ]); 75 | 76 | $backTrace = debug_backtrace(true, 5); 77 | 78 | // generates a simplified backtrace 79 | $backTraceLog = []; 80 | foreach ($backTrace as $record) { 81 | // clean out args since these can cause recursion problems and isn't all that valuable anyway 82 | if (isset($record['args'])) { 83 | unset($record['args']); 84 | } 85 | $backTraceLog[] = $record; 86 | } 87 | $errorStore->stack = $backTraceLog; 88 | 89 | // push to result 90 | $result = $di->get('result', []); 91 | $result->addError($errorStore); 92 | 93 | // send to output 94 | $output = new Output; 95 | $output->setStatusCode(500, 'Error processing the request'); 96 | $output->send($result); 97 | 98 | } 99 | }); 100 | 101 | 102 | /** 103 | * Translates PHP's bit error codes into actual human text 104 | * @param int $code 105 | * @return int|string 106 | */ 107 | function errorTypeStr($code) 108 | { 109 | switch ($code) { 110 | case E_ERROR: 111 | return 'ERROR'; 112 | case E_WARNING: 113 | return 'WARNING'; 114 | case E_PARSE: 115 | return 'PARSE'; 116 | case E_NOTICE: 117 | return 'NOTICE'; 118 | case E_CORE_ERROR: 119 | return 'CORE ERROR'; 120 | case E_CORE_WARNING: 121 | return 'CORE WARNING'; 122 | case E_COMPILE_ERROR: 123 | return 'COMPILE ERROR'; 124 | case E_COMPILE_WARNING: 125 | return 'COMPILE WARNING'; 126 | case E_USER_ERROR: 127 | return 'USER ERROR'; 128 | case E_USER_WARNING: 129 | return 'USER WARNING'; 130 | case E_USER_NOTICE: 131 | return 'USER NOTICE'; 132 | case E_STRICT: 133 | return 'STRICT'; 134 | case E_RECOVERABLE_ERROR: 135 | return 'RECOVERABLE ERROR'; 136 | case E_DEPRECATED: 137 | return 'DEPRECATED'; 138 | case E_USER_DEPRECATED: 139 | return 'USER DEPRECATED'; 140 | default: 141 | return $code; 142 | } 143 | } 144 | 145 | /** 146 | * custom error handler function will process regular PHP errors 147 | * and convert them to ErrorStore objects then run them through our regular api processing 148 | * 149 | * @param $errno 150 | * @param $errstr 151 | * @param $errfile 152 | * @param $errline 153 | * @param \Throwable|mixed $context 154 | * @param string $title 155 | */ 156 | 157 | set_error_handler(function ($errno, $errstr, $errfile, $errline, $context = null, string $title = '') use ( 158 | $app, 159 | $config, 160 | $di 161 | ) { 162 | // clean any pre-existing error text output to the screen 163 | ob_clean(); 164 | 165 | //ignore suppressed errors 166 | if (error_reporting() == 0) { 167 | return; 168 | } 169 | 170 | 171 | $errorStore = new \PhalconRest\Exception\ErrorStore([ 172 | 'code' => $errno, 173 | 'title' => $title ?: 'Fatal Error Ocurred - ' . errorTypeStr($errno), 174 | 'more' => $errstr, 175 | 'context' => $context, 176 | 'line' => $errline, 177 | 'file' => $errfile 178 | ]); 179 | 180 | 181 | if ($context instanceof \Throwable) { 182 | if ($previous = $context->getPrevious()) { 183 | $errorStore->context = '[Previous] ' . (string)$previous; //todo: could recurse the creation of exception details 184 | } else { 185 | $errorStore->context = null; 186 | } 187 | $backTrace = explode("\n", $context->getTraceAsString()); 188 | array_walk($backTrace, function (&$line) { 189 | $line = preg_replace('/^#\d+ /', '', $line); 190 | }); 191 | } else { 192 | $errorStore->context = $context; 193 | $backTrace = debug_backtrace(true, 5); //FIXME: shouldn't backtrace be shown only in debug mode? 194 | } 195 | 196 | // generates a simplified backtrace 197 | $backTraceLog = []; 198 | foreach ($backTrace as $record) { 199 | // clean out args since these can cause recursion problems and isn't all that valuable anyway 200 | if (isset($record['args'])) { 201 | unset($record['args']); 202 | } 203 | $backTraceLog[] = $record; 204 | } 205 | $errorStore->stack = $backTraceLog; 206 | 207 | // push to result 208 | $result = $di->get('result', []); 209 | $result->addError($errorStore); 210 | 211 | //send to output 212 | $output = new Output; 213 | $output->setStatusCode(500, 'Error processing the request'); 214 | $output->send($result); 215 | }); 216 | -------------------------------------------------------------------------------- /src/bin/loader.php: -------------------------------------------------------------------------------- 1 | $config['application']['modelsDir'], 23 | 'PhalconRest\Entities' => $config['application']['entitiesDir'], 24 | 'PhalconRest\Controllers' => $config['application']['controllersDir'], 25 | 'PhalconRest\Exceptions' => $config['application']['exceptionsDir'], 26 | 'PhalconRest\Libraries' => $config['application']['librariesDir'], 27 | 'PhalconRest\Responses' => $config['application']['responsesDir'] 28 | ]; 29 | 30 | // load Composer Namespaces 31 | $map = require COMPOSER_PATH . 'composer/autoload_namespaces.php'; 32 | foreach ($map as $nameSpace => $path) { 33 | $nameSpace = trim($nameSpace, '\\'); 34 | if (!isset($namespaces[$nameSpace])) { 35 | // use the first key in the path array for now.... 36 | $nameSpaces[$nameSpace] = $path[0]; 37 | } 38 | } 39 | $loader->registerNamespaces($nameSpaces); 40 | 41 | // load Composer Classes 42 | $classMap = require COMPOSER_PATH . 'composer/autoload_classmap.php'; 43 | $loader->registerClasses($classMap); 44 | 45 | // load Composer Files 46 | // careful with this one since it pollutes the global name space 47 | $autoLoadFilesPath = COMPOSER_PATH . 'composer/autoload_files.php'; 48 | if (file_exists($autoLoadFilesPath)) { 49 | $files = require $autoLoadFilesPath; 50 | foreach ($files as $file) { 51 | require_once $file; 52 | } 53 | } 54 | 55 | $loader->register(); 56 | 57 | // now init Phalcon DI object with services required by the core API 58 | require_once "services.php"; 59 | 60 | // load logic to bootstrap the web app 61 | if (PHP_SAPI == 'cli') { 62 | require_once 'bootstrap/cli.php'; 63 | } else { 64 | require_once 'bootstrap/web.php'; 65 | 66 | // include special error handling logic for JSON output 67 | require "errorHandler.php"; 68 | } -------------------------------------------------------------------------------- /src/bin/maintenanceRoute.php: -------------------------------------------------------------------------------- 1 | setHandler(new class 4 | { 5 | function maintenance() 6 | { 7 | throw new \PhalconRest\Exception\HTTPException('Maintenance mode', 503, 8 | ['more' => 'Server is down for maintenance, and will be back shortly.']); 9 | } 10 | }); 11 | 12 | $catchAll->map('/:params', 'maintenance'); //:params is a "catch-the-rest" regex 13 | return [$catchAll]; -------------------------------------------------------------------------------- /src/bin/services.php: -------------------------------------------------------------------------------- 1 | start('Booting App'); 13 | 14 | /** 15 | * The DI is our dependency injector. 16 | * It will store pointers to all of our services 17 | * and we will insert it into all of our controllers. 18 | * @var $di FactoryDefault\Cli|FactoryDefault 19 | */ 20 | 21 | $di = (PHP_SAPI == 'cli') ? new FactoryDefault\Cli : new FactoryDefault; 22 | 23 | // load the proper request object depending on the specified format 24 | $di->setShared('request', function () use ($config) { 25 | if (isset($config['application']['outputFormat'])) { 26 | $outputFormat = $config['application']['outputFormat']; 27 | } else { 28 | $outputFormat = 'JsonApi'; 29 | } 30 | $classpath = '\PhalconRest\Request\Adapters\\' . $outputFormat; 31 | $request = new $classpath(); 32 | $request->defaultCaseFormat = $config['application']['propertyFormatFrom']; 33 | return $request; 34 | }); 35 | 36 | // stopwatch service to track 37 | $di->setShared('stopwatch', function () use ($T) { 38 | return $T; 39 | }); 40 | 41 | $di->setShared('logger', function () use ($config) { 42 | return new FileLogger($config['application']['loggingDir'] . date('d_m_y') . '-api.log'); 43 | }); 44 | 45 | /** 46 | * load rule store to support business rules 47 | */ 48 | // hold custom variables 49 | $di->set('ruleList', function () { 50 | return new \PhalconRest\Rules\Registry(); 51 | }, true); 52 | 53 | if (PHP_SAPI != 'cli') { 54 | /** 55 | * Return array of the Collections, which define a group of routes, from 56 | * routes/collections. 57 | * These will be mounted into the app itself later. 58 | */ 59 | $di->set('collections', function () use ($config) { 60 | return $config['application']['maintenance'] ? 61 | include('maintenanceRoute.php') : 62 | include('../app/routes/routeLoader.php'); 63 | }); 64 | } 65 | 66 | // return a single copy of the config array 67 | $di->setShared('config', function () use ($config) { 68 | return $config; 69 | }); 70 | 71 | 72 | // As soon as we request the session service, it will be started. 73 | $di->setShared('session', function () { 74 | $session = new \Phalcon\Session\Adapter\Files(); 75 | $session->start(); 76 | return $session; 77 | }); 78 | 79 | /** 80 | * Generates one caching instance, or an array of, given the "cache" config option. 81 | * If none is found, a default FileCache is created. 82 | * 83 | * Explanation about the "cache" config entry: 84 | * It should be an array of arrays, as follows: 85 | * First, the zeroed entry is the cache class; the rest are config options for that cache entry. 86 | * If "front" option is specified, it should follow the same structure (0 => class, rest => options); the default is 87 | * "Frontend\Data" (what is needed in most cases). The storage path is taken from "fileStorage" config if needed. 88 | * Example: 89 | * 'cache' => [ 90 | * [Cache\Backend\Redis::class, 'front' => ['lifetime' => 300]], //5 min 91 | * [Cache\Backend\File::class, 'cacheDir' => '/tmp/cache'], 92 | * [ 93 | * Cache\Backend\File::class, 94 | * 'front' => [Cache\Frontend\Base64, 'lifetime' => 172800], //2 days, in seconds 95 | * 'cacheDir' => '/tmp/cache-blobs' 96 | * ], 97 | * ] 98 | * 99 | * @return Cache\Multiple|Cache\BackendInterface 100 | */ 101 | $di->setShared('cache', function () use ($config) { 102 | $instances = array_map(function ($cacheConfig) use ($config) { 103 | //creates a caching frontend for this interface 104 | if (isset($cacheConfig['front'])) { 105 | //gets the type or defaults to the most common frontend: serialize()-based 106 | $frontType = array_shift($cacheConfig['front']) ?: Cache\Frontend\Data::class; 107 | $frontOptions = $cacheConfig['front']; 108 | unset($cacheConfig['front']); 109 | } else { 110 | $frontType = new Cache\Frontend\Data; 111 | $frontOptions = []; 112 | } 113 | if (!isset($frontOptions['lifetime'])) { 114 | $frontOptions['lifetime'] = 60 * 60; //2 hours is the default lifetime if none is given 115 | } 116 | $front = new $frontType($frontOptions); 117 | 118 | //now we identify the backend type. Fallback to an ephemeral caching if nothing is given 119 | $type = array_shift($cacheConfig) ?: Cache\Backend\Memory::class; 120 | 121 | //default values / house-keeping for File-based caching 122 | if ($type == Cache\Backend\File::class && !isset($cacheConfig['cacheDir'])) { 123 | $cacheConfig['cacheDir'] = $config['application']['cacheDir']; 124 | } 125 | 126 | return new $type($front, $cacheConfig); 127 | }, $config['cache'] ?? [\Phalcon\Cache\Backend\File::class]); 128 | 129 | //packs the cache instances in a Multiple cache if needed 130 | return (sizeof($instances) > 1) ? new Cache\Multiple($instances) : current($instances); 131 | }); 132 | 133 | // load a result adapter based on what is configured in the app 134 | //$di->setShared('result', function () use ($config) { 135 | // if (isset($config['application']['outputFormat'])) { 136 | // $outputFormat = $config['application']['outputFormat']; 137 | // } else { 138 | // $outputFormat = 'JsonApi'; 139 | // } 140 | // $classpath = '\PhalconRest\Result\Adapters\\' . $outputFormat . '\Result'; 141 | // return new $classpath(); 142 | //}); 143 | 144 | $di->setShared('result', [ 145 | 'className' => "\\PhalconRest\\Result\\Adapters\\" . $config['application']['outputFormat'] . "\\Result", 146 | 'arguments' => [ 147 | ['type' => 'parameter'] 148 | ] 149 | ]); 150 | 151 | // load a data adapter based on what is configured in the app 152 | $di->set('data', [ 153 | 'className' => "\\PhalconRest\\Result\\Adapters\\" . $config['application']['outputFormat'] . "\\Data", 154 | 'arguments' => [ 155 | ['type' => 'parameter'], 156 | ['type' => 'parameter'], 157 | ['type' => 'parameter'], 158 | ['type' => 'parameter'] 159 | ] 160 | ]); 161 | 162 | $di->setShared('modelsManager', function () { 163 | return new \Phalcon\Mvc\Model\Manager(); 164 | }); 165 | 166 | $di->set('modelsMetadata', function () use ($config) { 167 | $metaData = new \Phalcon\Mvc\Model\Metadata\Files([ 168 | 'metaDataDir' => $config['application']['tempDir'] 169 | ]); 170 | return $metaData; 171 | }); 172 | 173 | // used in model? 174 | $di->setShared('memory', function () { 175 | return new \Phalcon\Mvc\Model\MetaData\Memory(); 176 | }); 177 | 178 | $di->set('queryBuilder', [ 179 | 'className' => '\\PhalconRest\\Query\\QueryBuilder', 180 | 'arguments' => [ 181 | ['type' => 'parameter'], 182 | ['type' => 'parameter'], 183 | ['type' => 'parameter'] 184 | ] 185 | ]); 186 | 187 | // phalcon inflector? 188 | $di->setShared('inflector', function () { 189 | return new Inflector(); 190 | }); 191 | 192 | /** 193 | * If our request contains a body, it has to be valid JSON. 194 | * This parses the body into a standard Object and makes that available from the DI. 195 | * If this service is called from a function, and the request body is not valid JSON or is empty, 196 | * the program will throw an Exception. 197 | */ 198 | $di->setShared('requestBody', function () { 199 | $in = file_get_contents('php://input'); 200 | $in = json_decode($in, false); 201 | 202 | // JSON body could not be parsed, throw exception 203 | if ($in === null) { 204 | throw new HTTPException('There was a problem understanding the data sent to the server by the application.', 205 | 409, [ 206 | 'dev' => 'The JSON body sent to the server was unable to be parsed.', 207 | 'code' => '5', 208 | 'more' => json_last_error() . ' - ' . json_last_error_msg() 209 | ]); 210 | } 211 | 212 | return $in; 213 | }); 214 | 215 | // hold custom variables 216 | $di->set('store', function () { 217 | $myObject = new class 218 | { 219 | private $store = []; 220 | 221 | public function update($key, $value) 222 | { 223 | $this->store[$key] = $value; 224 | } 225 | 226 | public function get($key) 227 | { 228 | if (array_key_exists($key, $this->store)) { 229 | return $this->store[$key]; 230 | } 231 | return null; 232 | } 233 | }; 234 | return $myObject; 235 | 236 | }, true); --------------------------------------------------------------------------------