├── .editorconfig ├── .gitattributes ├── .gitignore ├── .scrutinizer.yml ├── .travis.yml ├── LICENSE ├── README.md ├── _config.php ├── _config └── config.yml ├── apigen.neon ├── composer.json ├── doc ├── DefaultPermissionManager.md ├── DefaultQueryHandler.md ├── DefaultSerializer.md ├── RESTfulAPI.md └── TokenAuthenticator.md ├── phpunit.xml ├── src ├── Authenticators │ ├── Authenticator.php │ └── TokenAuthenticator.php ├── Extensions │ ├── GroupExtension.php │ └── TokenAuthExtension.php ├── PermissionManagers │ ├── DefaultPermissionManager.php │ └── PermissionManager.php ├── QueryHandlers │ ├── DefaultQueryHandler.php │ └── QueryHandler.php ├── RESTfulAPI.php ├── RESTfulAPIError.php ├── Serializers │ ├── DeSerializer.php │ ├── DefaultDeSerializer.php │ ├── DefaultSerializer.php │ └── Serializer.php └── ThirdParty │ └── Inflector │ ├── Inflector.php │ └── LICENSE.txt └── tests ├── .upgrade.yml ├── API └── RESTfulAPITest.php ├── Authenticators └── TokenAuthenticatorTest.php ├── Fixtures ├── ApiTestAuthor.php ├── ApiTestBook.php ├── ApiTestLibrary.php ├── ApiTestProduct.php └── ApiTestWidget.php ├── PermissionManagers └── DefaultPermissionManagerTest.php ├── QueryHandlers └── DefaultQueryHandlerTest.php ├── RESTfulAPITester.php └── Serializers ├── DefaultDeSerializerTest.php └── DefaultSerializerTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in this file, 2 | # please see the EditorConfig documentation: 3 | # http://editorconfig.org 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{*.yml,package.json}] 14 | indent_size = 2 15 | 16 | # The indent size used in the package.json file cannot be changed: 17 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | 131 | # NuGet Packages Directory 132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 133 | #packages/ 134 | 135 | # Windows Azure Build Output 136 | csx 137 | *.build.csdef 138 | 139 | # Windows Store app package directory 140 | AppPackages/ 141 | 142 | # Others 143 | sql/ 144 | *.Cache 145 | ClientBin/ 146 | [Ss]tyle[Cc]op.* 147 | ~$* 148 | *~ 149 | *.dbmdl 150 | *.[Pp]ublish.xml 151 | *.pfx 152 | *.publishsettings 153 | 154 | # RIA/Silverlight projects 155 | Generated_Code/ 156 | 157 | # Backup & report files from converting an old project file to a newer 158 | # Visual Studio version. Backup files are not needed, because we have git ;-) 159 | _UpgradeReport_Files/ 160 | Backup*/ 161 | UpgradeLog*.XML 162 | UpgradeLog*.htm 163 | 164 | # SQL Server files 165 | App_Data/*.mdf 166 | App_Data/*.ldf 167 | 168 | ############# 169 | ## Windows detritus 170 | ############# 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac crap 183 | .DS_Store 184 | 185 | 186 | ############# 187 | ## Python 188 | ############# 189 | 190 | *.py[co] 191 | 192 | # Packages 193 | *.egg 194 | *.egg-info 195 | dist/ 196 | build/ 197 | eggs/ 198 | parts/ 199 | var/ 200 | sdist/ 201 | develop-eggs/ 202 | .installed.cfg 203 | 204 | # Installer logs 205 | pip-log.txt 206 | 207 | # Unit test / coverage reports 208 | .coverage 209 | .tox 210 | 211 | #Translations 212 | *.mo 213 | 214 | #Mr Developer 215 | .mr.developer.cfg 216 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | inherit: true 2 | 3 | checks: 4 | php: 5 | verify_property_names: true 6 | verify_argument_usable_as_reference: true 7 | verify_access_scope_valid: true 8 | useless_calls: true 9 | use_statement_alias_conflict: true 10 | variable_existence: true 11 | unused_variables: true 12 | unused_properties: true 13 | unused_parameters: true 14 | unused_methods: true 15 | unreachable_code: true 16 | too_many_arguments: true 17 | sql_injection_vulnerabilities: true 18 | simplify_boolean_return: true 19 | side_effects_or_types: true 20 | security_vulnerabilities: true 21 | return_doc_comments: true 22 | return_doc_comment_if_not_inferrable: true 23 | require_scope_for_properties: true 24 | require_scope_for_methods: true 25 | require_php_tag_first: true 26 | psr2_switch_declaration: true 27 | psr2_class_declaration: true 28 | property_assignments: true 29 | prefer_while_loop_over_for_loop: true 30 | precedence_mistakes: true 31 | precedence_in_conditions: true 32 | phpunit_assertions: true 33 | php5_style_constructor: true 34 | parse_doc_comments: true 35 | parameter_non_unique: true 36 | parameter_doc_comments: true 37 | param_doc_comment_if_not_inferrable: true 38 | optional_parameters_at_the_end: true 39 | one_class_per_file: true 40 | no_unnecessary_if: true 41 | no_trailing_whitespace: true 42 | no_property_on_interface: true 43 | no_non_implemented_abstract_methods: true 44 | no_error_suppression: true 45 | no_duplicate_arguments: true 46 | no_commented_out_code: true 47 | newline_at_end_of_file: true 48 | missing_arguments: true 49 | method_calls_on_non_object: true 50 | instanceof_class_exists: true 51 | foreach_traversable: true 52 | fix_line_ending: true 53 | fix_doc_comments: true 54 | duplication: true 55 | deprecated_code_usage: true 56 | deadlock_detection_in_loops: true 57 | code_rating: true 58 | closure_use_not_conflicting: true 59 | catch_class_exists: true 60 | blank_line_after_namespace_declaration: false 61 | avoid_multiple_statements_on_same_line: true 62 | avoid_duplicate_types: true 63 | avoid_conflicting_incrementers: true 64 | avoid_closing_tag: true 65 | assignment_of_null_return: true 66 | argument_type_checks: true 67 | 68 | filter: 69 | paths: [code/*, tests/*] 70 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 7.1 4 | env: 5 | matrix: 6 | - DB=MYSQL CORE_RELEASE=4 7 | global: 8 | secure: Le917O5p+3nccje9JNHyvFuQk44wkoXmfDYTV5tyfqH1yvTOS9aD2zUkSORbGBcxwFKbXxJxlhSH/TBub/ZjXoAlURw10oS8uzG5T4LVPkyKUNcph54Mbgs4E05K6IzOg78VlRZ6IOjBsXh/8NI51uEstgJZ/dajjPdERgjrd+k= 9 | before_script: 10 | - phpenv rehash 11 | - git clone git://github.com/silverstripe/silverstripe-travis-support.git ~/travis-support 12 | - php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss 13 | - cd ~/builds/ss 14 | script: 15 | - vendor/bin/phpunit vendor/colymba/silverstripe-restfulapi/tests/ --exclude-group CORSPreflight 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Thierry François (colymba) 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | :warning: I haven't been able to give as much love as I would like to these repos as they deserve. If you have time and are interested to help maintain them, give me a shout. :rotating_light: 2 | 3 | # SilverStripe RESTful API 4 | 5 | [![Build Status](https://travis-ci.org/colymba/silverstripe-restfulapi.png?branch=master)](https://travis-ci.org/colymba/silverstripe-restfulapi) 6 | 7 | This module implements a RESTful API for read/write access to your SilverStripe Models. It comes bundled with a default Token Authenticator, Query Handler and JSON Serializers, and can be extended to your need and to return XML or other content type via custom components. 8 | 9 | 10 | ## API URL structure 11 | 12 | | Action | HTTP Verb | URL | 13 | | :-------------------- | :-------- | :-------------------------------------- | 14 | | Find 1 record | `GET` | `api/Model/ID` | 15 | | Find multiple records | `GET` | `api/Model?param=val&__rand=1234` | 16 | | Update a record | `PUT` | `api/Model/ID` | 17 | | Create a record | `POST` | `api/Model` | 18 | | Delete a record | `DELETE` | `api/Model/ID` | 19 | | - | - | - | 20 | | Login & get token | n/a | `api/auth/login?email=***&pwd=***` | 21 | | Logout | n/a | `api/auth/logout` | 22 | | Password reset email | n/a | `api/auth/lostPassword?email=***` | 23 | | - | - | - | 24 | | Custom ACL methods | n/a | `api/acl/YOURMETHOD` | 25 | 26 | `Model` being the class name of the model you are querying (*name formatting may vary depending on DeSerializer used*). For example with a model class named `Book` URLs would look like: 27 | * `api/Book/33` 28 | * `api/Book?title=Henry` 29 | * `api/Book?title__StartsWith=Henry` 30 | * `api/Book?title__StartsWith=Henry&__rand=123456&__limit=1` 31 | * `api/Book?title__StartsWith=Henry&__rand=123456&__limit[]=10&__limit[]=5` 32 | 33 | The allowed `/auth/$Action` must be defined on the used `Authenticator` class via the `$allowed_actions` config. 34 | 35 | 36 | ## Requirements 37 | * [SilverStripe Framework 4+](https://github.com/silverstripe/silverstripe-framework) 38 | 39 | 40 | ## Quick features highlight 41 | * [Configurable components](#components) 42 | * [CORS enabled](doc/RESTfulAPI.md#cors) 43 | * [Embedded records](doc/RESTfulAPI.md#embedded-records) 44 | * [Sideloaded records (EmberDataSerializer)](doc/EmberDataSerializer.md#sideloaded-records) 45 | * [Authentication](doc/TokenAuthenticator.md) 46 | * [DataObject & Config level api access control](doc/RESTfulAPI.md#authentication-and-api-access-control) 47 | * [Search filter modifiers](doc/DefaultQueryHandler.md#search-filter-modifiers) 48 | 49 | 50 | ## What's all this? 51 | ### RESTfulAPI 52 | This is the main API Controller that receives all the requests, checks if authentication is needed and passing control to the authenticator if true, the resquest is then passed on to the QueryHandler, which uses the DeSerializer to figure out model & column names and decode the eventual payload from the client, the query result is then passed to the Serializer to be formatted and then returned to the client. 53 | 54 | If CORS are enabled (true by default), the right headers are taken care of too. 55 | 56 | 57 | ### Components 58 | The `RESTfulAPI` uses 4 types of components, each implementing a different interface: 59 | * Authetication (`Authenticator`) 60 | * Permission Management (`PermissionManager`) 61 | * Query Handler (`QueryHandler`) 62 | * Serializer (`Serializer`) 63 | 64 | 65 | ### Default components 66 | This API comes with defaults for each of those components: 67 | * `TokenAuthenticator` handles authentication via a token in an HTTP header or variable 68 | * `DefaultPermissionManager` handles DataObject permission checks depending on the HTTP request 69 | * `DefaultQueryHandler` handles all find, edit, create or delete for models 70 | * `DefaultSerializer` / `DefaultDeSerializer` serialize query results into JSON and deserialize client payloads 71 | * `EmberDataSerializer` / `EmberDataDeSerializer` same as the `Default` version but with specific fomatting fo Ember Data. 72 | 73 | You can create you own classes by implementing the right interface or extending the existing components. When creating you own components, any error should be return as a `RESTfulAPIError` object to the `RESTfulAPI`. 74 | 75 | 76 | ### Token Authentication Extension 77 | When using `TokenAuthenticator` you must add the `TokenAuthExtension` `DataExtension` to a `DataObject` and setup `TokenAuthenticator` with the right config. 78 | 79 | **By default, API authentication is disabled.** 80 | 81 | 82 | ### Permissions management 83 | DataObject API access control can be managed in 2 ways. Through the `api_access` [YML config](doc/RESTfulAPI.md#authentication-and-api-access-control) allowing for simple configurations, or via [DataObject permissions](http://doc.silverstripe.org/framework/en/reference/dataobject#permissions) through a `PermissionManager` component. 84 | 85 | A sample `Group` extension `GroupExtension` is also available with a basic set of dedicated API permissions. This can be enabled via [config](code/_config/config.yml#L11) or you can create your own. 86 | 87 | **By default, the API only performs access control against the `api_access` YML config.** 88 | 89 | 90 | ### Config 91 | See individual component configuration file for mode details 92 | * [RESTfulAPI](doc/RESTfulAPI.md) the root of the api 93 | * [TokenAuthenticator](doc/TokenAuthenticator.md) handles query authentication via token 94 | * [DefaultPermissionManager](doc/DefaultPermissionManager.md) handles DataObject level permissions check 95 | * [DefaultQueryHandler](doc/DefaultQueryHandler.md) where most of the logic happens 96 | * [DefaultSerializer](doc/DefaultSerializer.md) DefaultSerializer and DeSerializer for everyday use 97 | * [EmberDataSerializer](doc/EmberDataSerializer.md) EmberDataSerializer and DeSerializer speicifrcally design for use with Ember Data and application/vnd.api+json 98 | 99 | Here is what a site's `config.yml` file could look like: 100 | ```yaml 101 | --- 102 | Name: mysite 103 | After: 104 | - 'framework/*' 105 | - 'cms/*' 106 | --- 107 | # API access 108 | Artwork: 109 | api_access: true 110 | Author: 111 | api_access: true 112 | Category: 113 | api_access: true 114 | Magazine: 115 | api_access: true 116 | Tag: 117 | api_access: 'GET,POST' 118 | Visual: 119 | api_access: true 120 | Image: 121 | api_access: true 122 | File: 123 | api_access: true 124 | Page: 125 | api_access: false 126 | # RestfulAPI config 127 | Colymba\RESTfulAPI\RESTfulAPI: 128 | authentication_policy: true 129 | access_control_policy: 'ACL_CHECK_CONFIG_AND_MODEL' 130 | dependencies: 131 | authenticator: '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator' 132 | authority: '%$Colymba\RESTfulAPI\PermissionManagers\DefaultPermissionManager' 133 | queryHandler: '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler' 134 | serializer: '%$Colymba\RESTfulAPI\Serializers\EmberData\EmberDataSerializer' 135 | cors: 136 | Enabled: true 137 | Allow-Origin: 'http://mydomain.com' 138 | Allow-Headers: '*' 139 | Allow-Methods: 'OPTIONS, GET' 140 | Max-Age: 86400 141 | # Components config 142 | Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler\DefaultQueryHandler: 143 | dependencies: 144 | deSerializer: '%$Colymba\RESTfulAPI\Serializers\EmberData\EmberDataDeSerializer' 145 | Colymba\RESTfulAPI\Serializers\EmberData\EmberDataSerializer: 146 | sideloaded_records: 147 | Artwork: 148 | - 'Visuals' 149 | - 'Authors' 150 | ``` 151 | 152 | 153 | ## Todo 154 | * API access IP throttling (limit request per minute for each IP or token) 155 | * Check components interface implementation 156 | 157 | 158 | ## License 159 | [BSD 3-clause license](LICENSE) 160 | 161 | Copyright (c) 2018, Thierry Francois (colymba) 162 | All rights reserved. 163 | -------------------------------------------------------------------------------- /_config.php: -------------------------------------------------------------------------------- 1 | > apigen --config apigen.neon 3 | 4 | # Source file or directory to parse 5 | source: code 6 | 7 | # Directory where to save the generated documentation 8 | destination: doc/api 9 | 10 | # List of allowed file extensions 11 | extensions: [php] 12 | 13 | # Character set of source files 14 | charset: auto 15 | 16 | # Main project name prefix 17 | main: SS_RESTfulAPI 18 | 19 | # Title of generated documentation 20 | title: SilverStripe RESTful API 21 | 22 | # Generate documentation for methods and properties with given access level 23 | accessLevels: [private, public, protected] 24 | 25 | # Generate tree view of classes, interfaces and exceptions 26 | tree: No 27 | 28 | # Add a link to download documentation as a ZIP archive 29 | download: No 30 | 31 | # Wipe out the destination directory first 32 | wipeout: Yes -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "colymba/silverstripe-restfulapi", 3 | "type": "silverstripe-vendormodule", 4 | "description": "SilverStripe RESTful API with a default JSON serializer.", 5 | "homepage": "https://github.com/colymba/silverstripe-restfulapi", 6 | "keywords": ["silverstripe", "api", "REST", "REST API", "RESTful", "json api", "model serializer", "REST server", "RESTful server"], 7 | "license": "BSD-3-Clause", 8 | "authors": [{ 9 | "name": "Thierry Francois", 10 | "homepage": "http://colymba.com" 11 | }], 12 | "repositories": [{ 13 | "type": "vcs", 14 | "url": "git@github.com:colymba/silverstripe-restfulapi.git" 15 | }], 16 | "require": { 17 | "silverstripe/framework": "~4.1" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "~5.7@stable" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Colymba\\RESTfulAPI\\": "src/", 25 | "Colymba\\RESTfulAPI\\Tests\\": "tests/" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /doc/DefaultPermissionManager.md: -------------------------------------------------------------------------------- 1 | # PermissionManagers\DefaultPermissionManager 2 | 3 | This component will check access permission against a DataObject for a given Member. The request HTTP method, will be match against a DataObject's `can()` method. 4 | 5 | Config | Type | Info | Default 6 | --- | :---: | --- | --- 7 | `n/a` | `n/a` | n/a | n/a 8 | 9 | 10 | Permission checks should be implemented on your DataObject with the `canView`, `canCreate`, `canEdit`, `canDelete` methods. See SilverStripe [documentation](http://doc.silverstripe.org/framework/en/reference/dataobject#permissions) for more information. 11 | -------------------------------------------------------------------------------- /doc/DefaultQueryHandler.md: -------------------------------------------------------------------------------- 1 | # QueryHandlers\DefaultQueryHandler 2 | 3 | This component handles database queries, utilize the deserializer to figure out models and column names and returns the data to the RESTfulAPI. 4 | 5 | Config | Type | Info | Default 6 | --- | :---: | --- | --- 7 | | `dependencies` | `array` | key => value pair specifying which deserializer to use | 'deSerializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer' 8 | | `searchFilterModifiersSeparator` | `string` | Separator used in HTTP params between the column name and the search filter modifier (e.g. ?name__StartsWith=Henry will find models with the column name that starts with 'Henry'. ORM equivalent *->filter(array('name::StartsWith' => 'Henry'))* ) | '__' 9 | | `skipedQueryParameters` | `array` | Uppercased query params that would not parsed as column names (uppercased) | 'URL', 'FLUSH', 'FLUSHTOKEN' 10 | | `max_records_limit` | `int` | specify the maximum number of records to return by default (avoid the api returning millions...) | 100 11 | | `models` | `array` | Array of mappings of URL segments to class names | [] 12 | 13 | ## Search filter modifiers 14 | This also accept search filter modifiers in HTTP variables (see [Search Filter Modifiers](http://doc.silverstripe.org/framework/en/topics/datamodel#search-filter-modifiers)) like: 15 | * ?columnNAme__StartsWith=Ba 16 | 17 | As well as special modifiers `sort`, `rand` and `limit` with these possible formatting: 18 | * ?columnName__sort=ASC 19 | * ?__rand 20 | * ?__rand=seed 21 | * ?__limit=count 22 | * ?__limit[]=count&__limit[]=offset 23 | 24 | Search filter modifiers are recognised/extracted thanks to the `searchFilterModifiersSeparator` config. The above examples assume the default `searchFilterModifiersSeparator` is in use. 25 | 26 | ## Model mappings 27 | 28 | Using the `models` configuration option it is possible to map the URL segment that follows `/api/` to a particular model class name. This can be used to override the default behaviour which will use a lower cased version of the model name, for example `Member` will become `/api/member`. 29 | 30 | It is a requirement to use this mapping when exposing namespaced classes because they do not map to a single URL segment. 31 | 32 | Example: 33 | ```yaml 34 | Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler: 35 | models: 36 | member: SilverStripe\Security\Member 37 | ``` 38 | 39 | ## Hooks 40 | 41 | Model hooks are available on both serialization and deserialization. These can be used to control what of the model data gets serialized (eg. sent to the client) or what will gets written into the model after deserialization. 42 | 43 | Here are the available callbacks (can be directly implemented on the `DataObject` or in a `DataExtension`) 44 | 45 | Signature | Parameter type | Info 46 | --- | :---: | --- 47 | `onBeforeSerialize()` | `void` | Called before the model is being serialized. You can set fields to `null` or use `unset` if you don't want them to be serialized. 48 | `onAfterSerialize(&$data)` | `array` | Called after the model has been serialized. This is the complete dataset that will be converted to JSON and sent to the client. You can use `unset` and/or add fields to the data, just like with a regular array. 49 | `onBeforeDeserialize(&$data)` | `string` | Called before the raw JSON is being parsed. You get access to the raw JSON data sent by the client. 50 | `onAfterDeserialize(&$data)` | `array` | Called after JSON has been deserialized into an array map. You can modify this array to prevent incoming values to be applied to your model (sanitize incoming data). 51 | -------------------------------------------------------------------------------- /doc/DefaultSerializer.md: -------------------------------------------------------------------------------- 1 | # Serializers\DefaultSerializer & Serializers\DefaultDeSerializer 2 | 3 | This component will serialize the data returned by the QueryHandler into JSON. No special formatting is performed on the JSON output (column names are returned as is), DataObject are returns as objects {} and DataLists as array or objects [{},{}]. 4 | 5 | Config | Type | Info | Default 6 | --- | :---: | --- | --- 7 | `n/a` | `n/a` | n/a | n/a 8 | 9 | 10 | ## Embedded records 11 | 12 | This serializer will use the `RESTfulAPI` `embedded_records` config. 13 | 14 | 15 | ## Hooks 16 | 17 | You can define an `onBeforeSerialize()` function on your model to add/remove field to your model before being serialized (i.e. remove Password from Member). 18 | 19 | ## Specifying fields to use 20 | 21 | You can specify which fields you'd like included in the API output for a DataObject: 22 | 23 | ```yaml 24 | Book: 25 | api_fields: 26 | - Title 27 | - Pages 28 | - Author # a related model 29 | Author: 30 | api_fields: 31 | - Name 32 | ``` 33 | 34 | In the above example, if you requested a Book you would receive it's Title, Pages and related Author object (a 35 | `has_one` relation). The Author returned would have a Name. Entity IDs will remain in place as well. 36 | 37 | If you don't specify anything for a DataObject's `api_fields` configuration setting, the standard dataset will be 38 | returned. 39 | 40 | It's also important to note that in the above example, API requests for Author would only ever return the Name field. 41 | If you wanted it to be only for requests for a book, you can use the `onBeforeSerialize()` extension method to set 42 | the config dynamically: 43 | 44 | ```yaml 45 | Book: 46 | extensions: 47 | - BookAuthorApiExtension 48 | ``` 49 | 50 | ```php 51 | class BookAuthorApiExtension extends DataExtension 52 | { 53 | /** 54 | * Only return the Author's Name when it's accessed through a Book. 55 | */ 56 | public function onBeforeSerialize() 57 | { 58 | Config::inst()->update(Author::class, 'api_fields', array('Name')); 59 | } 60 | } 61 | ``` 62 | -------------------------------------------------------------------------------- /doc/RESTfulAPI.md: -------------------------------------------------------------------------------- 1 | # RESTfulAPI 2 | 3 | This handles/redirect all api request. The API is accessed via the `api/` url (can be changed with a `Director` rule). The `api/auth/ACTION` request will need the Authentication component to have the *ACTION* defined. 4 | 5 | If `api/` isn't a suitable access point for the api, this can be changed via config: 6 | ```yaml 7 | Director: 8 | rules: 9 | 'restapi': 'RESTfulAPI' 10 | ``` 11 | 12 | | Config | Type | Info | Default 13 | | --- | :---: | --- | --- 14 | | `authentication_policy` | `boolean`/`array` | If true, the API will use authentication, if false|null no authentication required. Or an array of HTTP methods that require authentication | false 15 | | `access_control_policy` | `boolean`/`string` | Lets you select which access control checks the API will perform or none at all. | 'ACL_CHECK_CONFIG_ONLY' 16 | | `dependencies` | `array` | key => value pairs sepcifying the components classes used for the `'authenticator'`, `'queryHandler'` and `'serializer'` | 'authenticator' => '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator', 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer' 17 | | `embedded_records` | `array` | key => value pairs sepcifying which relation names to embed in the response and for which model this applies (i.e. 'RequestedClass' => array('RelationNameToEmbed')) | n/a 18 | | - | - | - | - 19 | | `cors` | `array` | Cross-Origin Resource Sharing (CORS) API settings | 20 | | `cors.Enabled` | `boolean` | If true the API will add CORS HTTP headers to the response | true 21 | | `cors.Allow-Origin` | `string` or `array` | '\*' allows all, 'http://domain.com' allows a specific domain, array('http://domain.com', 'http://site.com') allows a list of domains | '\*' 22 | | `cors.Allow-Headers` | `string` | '\*' allows all, 'header1, header2' coman separated list allows a list of headers | '\*' 23 | | `cors.Allow-Methods` | `string` | 'HTTPMETHODE1, HTTPMETHODE12' coma separated list of HTTP methodes to allow | 'OPTIONS, POST, GET, PUT, DELETE' 24 | | `cors.Max-Age` | `integer` | Preflight/OPTIONS request caching time in seconds | 86400 25 | 26 | 27 | ## CORS (Cross-Origin Resource Sharing) 28 | 29 | This is nescassary if the api is access from a different domain. See [using CORS](http://www.html5rocks.com/en/tutorials/cors/) for more infos. 30 | 31 | 32 | ## Authentication and api access control 33 | By default, the api will refuse access to any model/dataObject which doesn't have it's `api_access` config var explicitly enabled. So for generic use and just limiting which models are accessible, an authenticator component isn't nescessary. 34 | 35 | Note that the DataObject's `api_access` config can either be: 36 | * unset|false: all requests to this model will be rejected 37 | * true: all requests will be allowed 38 | * array of HTTP methods: only requests with the HTTP method in the config will be allowed (i.e. GET, POST) 39 | 40 | If you require a more fined tune permission management, you can change the `access_control_policy` to perform model checks. This will be handled by the defined `authority` component like [DefaultPermissionManager](DefaultPermissionManager.md). 41 | 42 | See the [api_access_control()](../code/RESTfulAPI.php#L519) function for more details. 43 | 44 | 45 | ### Access control considerations 46 | The API (with default components) will call the `api_access_control` method (making any configured checks) for each `find`, `create`, `update`, `delete` operations as well as during serialization of the data. Using a `PermissionManager` may impact performace and you should concider carefully your permission sets to avoid unexpected results. 47 | 48 | Note that when configuring `api_access_control` to do checks on the DataObject level via a `PermissionManager`, the Member model passed to the Permission Manager is the one returned by the `Authenticator` `getOwner()` method. If the returned owner isn't an instance of Member, `null` will be passed instead. 49 | 50 | A sample `Group` extension `Colymba\RESTfulAPI\Extensions\GroupExtension` is also available with a basic set of dedicated API permissions and User Groups. This can be enabled via [config](../code/_config/config.yml#L11) or you can create your own. 51 | 52 | 53 | ## Embedded records 54 | By default on the IDs of relations (has_one, has_many...) are returned to the client. To save HTTP request, these relation can be embedded into the payload, this is defined by the `embedded_records` config and used by the serializers. 55 | 56 | For more details about embeded records, [see the source comment](../code/RESTfulAPI.php#L106) on the config var. 57 | -------------------------------------------------------------------------------- /doc/TokenAuthenticator.md: -------------------------------------------------------------------------------- 1 | # Authenticators\TokenAuthenticator 2 | 3 | This component takes care of authenticating all API requests against a token stored in a HTTP header or a query var as fallback. 4 | 5 | The authentication token is returned by the `login` function. Also available, a `logout` function and `lostpassword` function that will email a password reset link to the user. 6 | 7 | The token can also be retrieved with an `TokenAuthenticator` instance calling the method `getToken()` and it can be reset via `resetToken()`. 8 | 9 | The `TokenAuthExtension` `DataExtension` must be applied to a `DataObject` and the `tokenOwnerClass` config updated with the correct classname. 10 | 11 | Config | Type | Info | Default 12 | --- | :---: | --- | --- 13 | `tokenLife` | `integer` | Authentication token life in seconds | 10800 14 | `tokenHeader` | `string` | Custom HTTP header storing the token | 'X-Silverstripe-Apitoken' 15 | `tokenQueryVar` | `string` | Fallback GET/POST HTTP query var storing the token | 'token' 16 | `tokenOwnerClass` | `string` | DataObject class name for the token's owner | 'Member' 17 | `autoRefreshLifetime` | `boolean` | Whether or not token lifetime should be updated with every request | false 18 | 19 | 20 | ## Token Authentication Data Extension `Colymba\RESTfulAPI\Extensions\TokenAuthExtension` 21 | This extension **MUST** be applied to a `DataObject` to use `TokenAuthenticator` and update the `tokenOwnerClass` config accordingly. e.g. 22 | ```yaml 23 | Member: 24 | extensions: 25 | - Colymba\RESTfulAPI\Extensions\TokenAuthExtension 26 | ``` 27 | ```yaml 28 | ApiUser: 29 | extensions: 30 | - Colymba\RESTfulAPI\Extensions\TokenAuthExtension 31 | TokenAuthenticator: 32 | tokenOwnerClass: 'ApiUser' 33 | ``` 34 | 35 | The `$db` keys can be changed to anything you want but keep the types to `Varchar(160)` and `Int`. 36 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | CORSPreflight 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Authenticators/Authenticator.php: -------------------------------------------------------------------------------- 1 | get(self::class, 'tokenLife'); 112 | $config['header'] = $configInstance->get(self::class, 'tokenHeader'); 113 | $config['queryVar'] = $configInstance->get(self::class, 'tokenQueryVar'); 114 | $config['owner'] = $configInstance->get(self::class, 'tokenOwnerClass'); 115 | $config['autoRefresh'] = $configInstance->get(self::class, 'autoRefreshLifetime'); 116 | 117 | $tokenDBColumns = $configInstance->get(TokenAuthExtension::class, 'db'); 118 | $tokenDBColumn = array_search('Varchar(160)', $tokenDBColumns); 119 | $expireDBColumn = array_search('Int', $tokenDBColumns); 120 | 121 | if ($tokenDBColumn !== false) { 122 | $config['DBColumn'] = $tokenDBColumn; 123 | } else { 124 | $config['DBColumn'] = 'ApiToken'; 125 | } 126 | 127 | if ($expireDBColumn !== false) { 128 | $config['expireDBColumn'] = $expireDBColumn; 129 | } else { 130 | $config['expireDBColumn'] = 'ApiTokenExpire'; 131 | } 132 | 133 | $this->tokenConfig = $config; 134 | } 135 | 136 | /** 137 | * Login a user into the Framework and generates API token 138 | * Only works if the token owner is a Member 139 | * 140 | * @param HTTPRequest $request HTTP request containing 'email' & 'pwd' vars 141 | * @return array login result with token 142 | */ 143 | public function login(HTTPRequest $request) 144 | { 145 | $response = array(); 146 | 147 | if ($this->tokenConfig['owner'] === Member::class) { 148 | $email = $request->requestVar('email'); 149 | $pwd = $request->requestVar('pwd'); 150 | $member = false; 151 | 152 | if ($email && $pwd) { 153 | $member = Injector::inst()->get(MemberAuthenticator::class)->authenticate( 154 | array( 155 | 'Email' => $email, 156 | 'Password' => $pwd, 157 | ), 158 | $request 159 | ); 160 | if ($member) { 161 | $tokenData = $this->generateToken(); 162 | 163 | $tokenDBColumn = $this->tokenConfig['DBColumn']; 164 | $expireDBColumn = $this->tokenConfig['expireDBColumn']; 165 | 166 | $member->{$tokenDBColumn} = $tokenData['token']; 167 | $member->{$expireDBColumn} = $tokenData['expire']; 168 | $member->write(); 169 | $member->login(); 170 | } 171 | } 172 | 173 | if (!$member) { 174 | $response['result'] = false; 175 | $response['message'] = 'Authentication fail.'; 176 | $response['code'] = self::AUTH_CODE_LOGIN_FAIL; 177 | } else { 178 | $response['result'] = true; 179 | $response['message'] = 'Logged in.'; 180 | $response['code'] = self::AUTH_CODE_LOGGED_IN; 181 | $response['token'] = $tokenData['token']; 182 | $response['expire'] = $tokenData['expire']; 183 | $response['userID'] = $member->ID; 184 | } 185 | } 186 | 187 | return $response; 188 | } 189 | 190 | /** 191 | * Logout a user from framework 192 | * and update token with an expired one 193 | * if token owner class is a Member 194 | * 195 | * @param HTTPRequest $request HTTP request containing 'email' var 196 | */ 197 | public function logout(HTTPRequest $request) 198 | { 199 | $email = $request->requestVar('email'); 200 | $member = Member::get()->filter(array('Email' => $email))->first(); 201 | 202 | if ($member) { 203 | //logout 204 | $member->logout(); 205 | 206 | if ($this->tokenConfig['owner'] === Member::class) { 207 | //generate expired token 208 | $tokenData = $this->generateToken(true); 209 | 210 | //write 211 | $tokenDBColumn = $this->tokenConfig['DBColumn']; 212 | $expireDBColumn = $this->tokenConfig['expireDBColumn']; 213 | 214 | $member->{$tokenDBColumn} = $tokenData['token']; 215 | $member->{$expireDBColumn} = $tokenData['expire']; 216 | $member->write(); 217 | } 218 | } 219 | } 220 | 221 | /** 222 | * Sends password recovery email 223 | * 224 | * @param HTTPRequest $request HTTP request containing 'email' vars 225 | * @return array 'email' => false if email fails (Member doesn't exist will not be reported) 226 | */ 227 | public function lostPassword(HTTPRequest $request) 228 | { 229 | $email = Convert::raw2sql($request->requestVar('email')); 230 | $member = DataObject::get_one(Member::class, "\"Email\" = '{$email}'"); 231 | 232 | if ($member) { 233 | $token = $member->generateAutologinTokenAndStoreHash(); 234 | 235 | $link = Security::lost_password_url(); 236 | $lostPasswordHandler = new LostPasswordHandler($link); 237 | 238 | $lostPasswordHandler->sendEmail($member, $token); 239 | } 240 | 241 | return array('done' => true); 242 | } 243 | 244 | /** 245 | * Return the stored API token for a specific owner 246 | * 247 | * @param integer $id ID of the token owner 248 | * @return string API token for the owner 249 | */ 250 | public function getToken($id) 251 | { 252 | if ($id) { 253 | $ownerClass = $this->tokenConfig['owner']; 254 | $owner = DataObject::get_by_id($ownerClass, $id); 255 | 256 | if ($owner) { 257 | $tokenDBColumn = $this->tokenConfig['DBColumn']; 258 | return $owner->{$tokenDBColumn}; 259 | } else { 260 | user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); 261 | } 262 | } else { 263 | user_error("TokenAuthenticator::getToken() requires an ID as argument.", E_USER_WARNING); 264 | } 265 | } 266 | 267 | /** 268 | * Reset an owner's token 269 | * if $expired is set to true the owner's will have a new invalidated/expired token 270 | * 271 | * @param integer $id ID of the token owner 272 | * @param boolean $expired if true the token will be invalidated 273 | */ 274 | public function resetToken($id, $expired = false) 275 | { 276 | if ($id) { 277 | $ownerClass = $this->tokenConfig['owner']; 278 | $owner = DataObject::get_by_id($ownerClass, $id); 279 | 280 | if ($owner) { 281 | //generate token 282 | $tokenData = $this->generateToken($expired); 283 | 284 | //write 285 | $tokenDBColumn = $this->tokenConfig['DBColumn']; 286 | $expireDBColumn = $this->tokenConfig['expireDBColumn']; 287 | 288 | $owner->{$tokenDBColumn} = $tokenData['token']; 289 | $owner->{$expireDBColumn} = $tokenData['expire']; 290 | $owner->write(); 291 | } else { 292 | user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); 293 | } 294 | } else { 295 | user_error("TokenAuthenticator::resetToken() requires an ID as argument.", E_USER_WARNING); 296 | } 297 | } 298 | 299 | /** 300 | * Generates an encrypted random token 301 | * and an expiry date 302 | * 303 | * @param boolean $expired Set to true to generate an outdated token 304 | * @return array token data array('token' => HASH, 'expire' => EXPIRY_DATE) 305 | */ 306 | private function generateToken($expired = false) 307 | { 308 | $life = $this->tokenConfig['life']; 309 | 310 | if (!$expired) { 311 | $expire = time() + $life; 312 | } else { 313 | $expire = time() - ($life * 2); 314 | } 315 | 316 | $generator = new RandomGenerator(); 317 | $tokenString = $generator->randomToken(); 318 | 319 | $e = PasswordEncryptor::create_for_algorithm('blowfish'); //blowfish isn't URL safe and maybe too long? 320 | $salt = $e->salt($tokenString); 321 | $token = $e->encrypt($tokenString, $salt); 322 | 323 | return array( 324 | 'token' => substr($token, 7), 325 | 'expire' => $expire, 326 | ); 327 | } 328 | 329 | /** 330 | * Returns the DataObject related to the token 331 | * that sent the authenticated request 332 | * 333 | * @param HTTPRequest $request HTTP API request 334 | * @return null|DataObject null if failed or the DataObject token owner related to the request 335 | */ 336 | public function getOwner(HTTPRequest $request) 337 | { 338 | $owner = null; 339 | 340 | //get the token 341 | $token = $request->getHeader($this->tokenConfig['header']); 342 | if (!$token) { 343 | $token = $request->requestVar($this->tokenConfig['queryVar']); 344 | } 345 | 346 | if ($token) { 347 | $SQLToken = Convert::raw2sql($token); 348 | 349 | $owner = DataObject::get_one( 350 | $this->tokenConfig['owner'], 351 | "\"" . $this->tokenConfig['DBColumn'] . "\"='" . $SQLToken . "'", 352 | false 353 | ); 354 | 355 | if (!$owner) { 356 | $owner = null; 357 | } 358 | } 359 | 360 | return $owner; 361 | } 362 | 363 | /** 364 | * Checks if a request to the API is authenticated 365 | * Gets API Token from HTTP Request and return Auth result 366 | * 367 | * @param HTTPRequest $request HTTP API request 368 | * @return true|RESTfulAPIError True if token is valid OR RESTfulAPIError with details 369 | */ 370 | public function authenticate(HTTPRequest $request) 371 | { 372 | //get the token 373 | $token = $request->getHeader($this->tokenConfig['header']); 374 | if (!$token) { 375 | $token = $request->requestVar($this->tokenConfig['queryVar']); 376 | } 377 | 378 | if ($token) { 379 | //check token validity 380 | return $this->validateAPIToken($token, $request); 381 | } else { 382 | //no token, bad news 383 | return new RESTfulAPIError(403, 384 | 'Token invalid.', 385 | array( 386 | 'message' => 'Token invalid.', 387 | 'code' => self::AUTH_CODE_TOKEN_INVALID, 388 | ) 389 | ); 390 | } 391 | } 392 | 393 | /** 394 | * Validate the API token 395 | * 396 | * @param string $token Authentication token 397 | * @param HTTPRequest $request HTTP API request 398 | * @return true|RESTfulAPIError True if token is valid OR RESTfulAPIError with details 399 | */ 400 | private function validateAPIToken($token, $request) 401 | { 402 | //get owner with that token 403 | $SQL_token = Convert::raw2sql($token); 404 | $tokenColumn = $this->tokenConfig['DBColumn']; 405 | 406 | $tokenOwner = DataObject::get_one( 407 | $this->tokenConfig['owner'], 408 | "\"" . $this->tokenConfig['DBColumn'] . "\"='" . $SQL_token . "'", 409 | false 410 | ); 411 | 412 | if ($tokenOwner) { 413 | //check token expiry 414 | $tokenExpire = $tokenOwner->{$this->tokenConfig['expireDBColumn']}; 415 | $now = time(); 416 | $life = $this->tokenConfig['life']; 417 | 418 | if ($tokenExpire > ($now - $life)) { 419 | // check if token should automatically be updated 420 | if ($this->tokenConfig['autoRefresh']) { 421 | $tokenOwner->setField($this->tokenConfig['expireDBColumn'], $now + $life); 422 | $tokenOwner->write(); 423 | } 424 | //all good, log Member in 425 | if (is_a($tokenOwner, Member::class)) { 426 | # this is a login without the logging 427 | Config::nest(); 428 | Config::modify()->set(Member::class, 'session_regenerate_id', true); 429 | $identityStore = Injector::inst()->get(IdentityStore::class); 430 | $identityStore->logIn($tokenOwner, false, $request); 431 | Config::unnest(); 432 | } 433 | 434 | return true; 435 | } else { 436 | //too old 437 | return new RESTfulAPIError(403, 438 | 'Token expired.', 439 | array( 440 | 'message' => 'Token expired.', 441 | 'code' => self::AUTH_CODE_TOKEN_EXPIRED, 442 | ) 443 | ); 444 | } 445 | } else { 446 | //token not found 447 | //not sure it's wise to say it doesn't exist. Let's be shady here 448 | return new RESTfulAPIError(403, 449 | 'Token invalid.', 450 | array( 451 | 'message' => 'Token invalid.', 452 | 'code' => self::AUTH_CODE_TOKEN_INVALID, 453 | ) 454 | ); 455 | } 456 | } 457 | } 458 | -------------------------------------------------------------------------------- /src/Extensions/GroupExtension.php: -------------------------------------------------------------------------------- 1 | ALL ACCESS 14 | * - API Editor => VIEW + EDIT + CREATE 15 | * - API Reader => VIEW 16 | * 17 | * @author Thierry Francois @colymba thierry@colymba.com 18 | * @copyright Copyright (c) 2013, Thierry Francois 19 | * 20 | * @license http://opensource.org/licenses/BSD-3-Clause BSD Simplified 21 | * 22 | * @package RESTfulAPI 23 | * @subpackage Permission 24 | */ 25 | class GroupExtension extends DataExtension implements PermissionProvider 26 | { 27 | /** 28 | * Basic RESTfulAPI Permission set 29 | * 30 | * @return Array Default API permission set 31 | */ 32 | public function providePermissions() 33 | { 34 | return array( 35 | 'RESTfulAPI_VIEW' => array( 36 | 'name' => 'Access records through the RESTful API', 37 | 'category' => 'RESTful API Access', 38 | 'help' => 'Allow for a user to access/view record(s) through the API', 39 | ), 40 | 'RESTfulAPI_EDIT' => array( 41 | 'name' => 'Edit records through the RESTful API', 42 | 'category' => 'RESTful API Access', 43 | 'help' => 'Allow for a user to submit a record changes through the API', 44 | ), 45 | 'RESTfulAPI_CREATE' => array( 46 | 'name' => 'Create records through the RESTful API', 47 | 'category' => 'RESTful API Access', 48 | 'help' => 'Allow for a user to create a new record through the API', 49 | ), 50 | 'RESTfulAPI_DELETE' => array( 51 | 'name' => 'Delete records through the RESTful API', 52 | 'category' => 'RESTful API Access', 53 | 'help' => 'Allow for a user to delete a record through the API', 54 | ), 55 | ); 56 | } 57 | 58 | /** 59 | * Create the default Groups 60 | * and add default admin to admin group 61 | */ 62 | public function requireDefaultRecords() 63 | { 64 | // Readers 65 | $readersGroup = DataObject::get(Group::class)->filter(array( 66 | 'Code' => 'restfulapi-readers', 67 | )); 68 | 69 | if (!$readersGroup->count()) { 70 | $readerGroup = new Group(); 71 | $readerGroup->Code = 'restfulapi-readers'; 72 | $readerGroup->Title = 'RESTful API Readers'; 73 | $readerGroup->Sort = 0; 74 | $readerGroup->write(); 75 | Permission::grant($readerGroup->ID, 'RESTfulAPI_VIEW'); 76 | } 77 | 78 | // Editors 79 | $editorsGroup = DataObject::get(Group::class)->filter(array( 80 | 'Code' => 'restfulapi-editors', 81 | )); 82 | 83 | if (!$editorsGroup->count()) { 84 | $editorGroup = new Group(); 85 | $editorGroup->Code = 'restfulapi-editors'; 86 | $editorGroup->Title = 'RESTful API Editors'; 87 | $editorGroup->Sort = 0; 88 | $editorGroup->write(); 89 | Permission::grant($editorGroup->ID, 'RESTfulAPI_VIEW'); 90 | Permission::grant($editorGroup->ID, 'RESTfulAPI_EDIT'); 91 | Permission::grant($editorGroup->ID, 'RESTfulAPI_CREATE'); 92 | } 93 | 94 | // Admins 95 | $adminsGroup = DataObject::get(Group::class)->filter(array( 96 | 'Code' => 'restfulapi-administrators', 97 | )); 98 | 99 | if (!$adminsGroup->count()) { 100 | $adminGroup = new Group(); 101 | $adminGroup->Code = 'restfulapi-administrators'; 102 | $adminGroup->Title = 'RESTful API Administrators'; 103 | $adminGroup->Sort = 0; 104 | $adminGroup->write(); 105 | Permission::grant($adminGroup->ID, 'RESTfulAPI_VIEW'); 106 | Permission::grant($adminGroup->ID, 'RESTfulAPI_EDIT'); 107 | Permission::grant($adminGroup->ID, 'RESTfulAPI_CREATE'); 108 | Permission::grant($adminGroup->ID, 'RESTfulAPI_DELETE'); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Extensions/TokenAuthExtension.php: -------------------------------------------------------------------------------- 1 | 'Varchar(160)', 26 | 'ApiTokenExpire' => 'Int', 27 | ); 28 | 29 | public function updateCMSFields(FieldList $fields) 30 | { 31 | $fields->removeByName('ApiToken'); 32 | $fields->removeByName('ApiTokenExpire'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/PermissionManagers/DefaultPermissionManager.php: -------------------------------------------------------------------------------- 1 | canView($member); 42 | break; 43 | 44 | case 'POST': 45 | return $model->canCreate($member); 46 | break; 47 | 48 | case 'PUT': 49 | return $model->canEdit($member); 50 | break; 51 | 52 | case 'DELETE': 53 | return $model->canDelete($member); 54 | break; 55 | 56 | default: 57 | return true; 58 | break; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/PermissionManagers/PermissionManager.php: -------------------------------------------------------------------------------- 1 | '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer', 46 | ); 47 | 48 | /** 49 | * Search Filter Modifiers Separator used in the query var 50 | * i.e. ?column__EndsWith=value 51 | * 52 | * @var string 53 | * @config 54 | */ 55 | private static $searchFilterModifiersSeparator = '__'; 56 | 57 | /** 58 | * Query vars to skip (uppercased) 59 | * 60 | * @var array 61 | * @config 62 | */ 63 | private static $skipedQueryParameters = array('URL', 'FLUSH', 'FLUSHTOKEN', 'TOKEN'); 64 | 65 | /** 66 | * Set a maximum numbers of records returned by the API. 67 | * Only affectects "GET All". Useful to avoid returning millions of records at once. 68 | * 69 | * Set to -1 to disable. 70 | * 71 | * @var integer 72 | * @config 73 | */ 74 | private static $max_records_limit = 100; 75 | 76 | /** 77 | * Map of model references from URL to class names for exposed models 78 | * 79 | * @var array 80 | * @config 81 | */ 82 | private static $models = []; 83 | 84 | /** 85 | * Stores the currently requested data 86 | * 87 | * @var array 88 | */ 89 | public $requestedData = array( 90 | 'model' => null, 91 | 'id' => null, 92 | 'params' => null, 93 | ); 94 | 95 | /** 96 | * Return current RESTfulAPI DeSerializer instance 97 | * 98 | * @return DeSerializer DeSerializer instance 99 | */ 100 | public function getdeSerializer() 101 | { 102 | return $this->deSerializer; 103 | } 104 | 105 | /** 106 | * All requests pass through here and are redirected depending on HTTP verb and params 107 | * 108 | * @param HTTPRequest $request HTTP request 109 | * @return DataObjec|DataList DataObject/DataList result or stdClass on error 110 | */ 111 | public function handleQuery(HTTPRequest $request) 112 | { 113 | //get requested model(s) details 114 | $model = $request->param('ModelReference'); 115 | 116 | $modelMap = Config::inst()->get(self::class, 'models'); 117 | 118 | if (array_key_exists($model, $modelMap)) { 119 | $model = $modelMap[$model]; 120 | } 121 | 122 | $id = $request->param('ID'); 123 | $response = false; 124 | $queryParams = $this->parseQueryParameters($request->getVars()); 125 | 126 | //validate Model name + store 127 | if ($model) { 128 | $model = $this->deSerializer->unformatName($model); 129 | if (!class_exists($model)) { 130 | return new RESTfulAPIError(400, 131 | "Model does not exist. Received '$model'." 132 | ); 133 | } else { 134 | //store requested model data and query data 135 | $this->requestedData['model'] = $model; 136 | } 137 | } else { 138 | //if model missing, stop + return blank object 139 | return new RESTfulAPIError(400, 140 | "Missing Model parameter." 141 | ); 142 | } 143 | 144 | //check API access rules on model 145 | if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { 146 | return new RESTfulAPIError(403, 147 | "API access denied." 148 | ); 149 | } 150 | 151 | //validate ID + store 152 | if (($request->isPUT() || $request->isDELETE()) && !is_numeric($id)) { 153 | return new RESTfulAPIError(400, 154 | "Invalid or missing ID. Received '$id'." 155 | ); 156 | } elseif ($id !== null && !is_numeric($id)) { 157 | return new RESTfulAPIError(400, 158 | "Invalid ID. Received '$id'." 159 | ); 160 | } else { 161 | $this->requestedData['id'] = $id; 162 | } 163 | 164 | //store query parameters 165 | if ($queryParams) { 166 | $this->requestedData['params'] = $queryParams; 167 | } 168 | 169 | //map HTTP word to module method 170 | switch ($request->httpMethod()) { 171 | case 'GET': 172 | return $this->findModel($model, $id, $queryParams, $request); 173 | break; 174 | case 'POST': 175 | return $this->createModel($model, $request); 176 | break; 177 | case 'PUT': 178 | return $this->updateModel($model, $id, $request); 179 | break; 180 | case 'DELETE': 181 | return $this->deleteModel($model, $id, $request); 182 | break; 183 | default: 184 | return new RESTfulAPIError(403, 185 | "HTTP method mismatch." 186 | ); 187 | break; 188 | } 189 | } 190 | 191 | /** 192 | * Parse the query parameters to appropriate Column, Value, Search Filter Modifiers 193 | * array( 194 | * array( 195 | * 'Column' => ColumnName, 196 | * 'Value' => ColumnValue, 197 | * 'Modifier' => ModifierType 198 | * ) 199 | * ) 200 | * 201 | * @param array $params raw GET vars array 202 | * @return array formatted query parameters 203 | */ 204 | public function parseQueryParameters(array $params) 205 | { 206 | $parsedParams = array(); 207 | $searchFilterModifiersSeparator = Config::inst()->get(self::class, 'searchFilterModifiersSeparator'); 208 | 209 | foreach ($params as $key__mod => $value) { 210 | // skip url, flush, flushtoken 211 | if (in_array(strtoupper($key__mod), Config::inst()->get(self::class, 'skipedQueryParameters'))) { 212 | continue; 213 | } 214 | 215 | $param = array(); 216 | 217 | $key__mod = explode( 218 | $searchFilterModifiersSeparator, 219 | $key__mod 220 | ); 221 | 222 | $param['Column'] = $this->deSerializer->unformatName($key__mod[0]); 223 | 224 | $param['Value'] = $value; 225 | 226 | if (isset($key__mod[1])) { 227 | $param['Modifier'] = $key__mod[1]; 228 | } else { 229 | $param['Modifier'] = null; 230 | } 231 | 232 | array_push($parsedParams, $param); 233 | } 234 | 235 | return $parsedParams; 236 | } 237 | 238 | /** 239 | * Finds 1 or more objects of class $model 240 | * 241 | * Handles column modifiers: :StartsWith, :EndsWith, 242 | * :PartialMatch, :GreaterThan, :LessThan, :Negation 243 | * and query modifiers: sort, rand, limit 244 | * 245 | * @param string $model Model(s) class to find 246 | * @param boolean|integr $id The ID of the model to find or false 247 | * @param array $queryParams Query parameters and modifiers 248 | * @param HTTPRequest $request The original HTTP request 249 | * @return DataObject|DataList Result of the search (note: DataList can be empty) 250 | */ 251 | public function findModel($model, $id = false, $queryParams, HTTPRequest $request) 252 | { 253 | if ($id) { 254 | $return = DataObject::get_by_id($model, $id); 255 | 256 | if (!$return) { 257 | return new RESTfulAPIError(404, 258 | "Model $id of $model not found." 259 | ); 260 | } elseif (!RESTfulAPI::api_access_control($return, $request->httpMethod())) { 261 | return new RESTfulAPIError(403, 262 | "API access denied." 263 | ); 264 | } 265 | } else { 266 | $return = DataList::create($model); 267 | $singletonModel = singleton($model); 268 | 269 | if (count($queryParams) > 0) { 270 | foreach ($queryParams as $param) { 271 | if ($param['Column'] && $singletonModel->hasDatabaseField($param['Column'])) { 272 | // handle sorting by column 273 | if ($param['Modifier'] === 'sort') { 274 | $return = $return->sort(array( 275 | $param['Column'] => $param['Value'], 276 | )); 277 | } 278 | // normal modifiers / search filters 279 | elseif ($param['Modifier']) { 280 | $return = $return->filter(array( 281 | $param['Column'] . ':' . $param['Modifier'] => $param['Value'], 282 | )); 283 | } 284 | // no modifier / search filter 285 | else { 286 | $return = $return->filter(array( 287 | $param['Column'] => $param['Value'], 288 | )); 289 | } 290 | } else { 291 | // random 292 | if ($param['Modifier'] === 'rand') { 293 | // rand + seed 294 | if ($param['Value']) { 295 | $return = $return->sort('RAND(' . $param['Value'] . ')'); 296 | } 297 | // rand only >> FIX: gen seed to avoid random result on relations 298 | else { 299 | $return = $return->sort('RAND(' . time() . ')'); 300 | } 301 | } 302 | // limits 303 | elseif ($param['Modifier'] === 'limit') { 304 | // range + offset 305 | if (is_array($param['Value'])) { 306 | $return = $return->limit($param['Value'][0], $param['Value'][1]); 307 | } 308 | // range only 309 | else { 310 | $return = $return->limit($param['Value']); 311 | } 312 | } 313 | } 314 | } 315 | } 316 | 317 | //sets default limit if none given 318 | $limits = $return->dataQuery()->query()->getLimit(); 319 | $limitConfig = Config::inst()->get(self::class, 'max_records_limit'); 320 | 321 | if (is_array($limits) && !array_key_exists('limit', $limits) && $limitConfig >= 0) { 322 | $return = $return->limit($limitConfig); 323 | } 324 | } 325 | 326 | return $return; 327 | } 328 | 329 | /** 330 | * Create object of class $model 331 | * 332 | * @param string $model 333 | * @param HTTPRequest $request 334 | * @return DataObject 335 | */ 336 | public function createModel($model, HTTPRequest $request) 337 | { 338 | if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { 339 | return new RESTfulAPIError(403, 340 | "API access denied." 341 | ); 342 | } 343 | 344 | $newModel = Injector::inst()->create($model); 345 | 346 | return $this->updateModel($newModel, $newModel->ID, $request); 347 | } 348 | 349 | /** 350 | * Update databse record or $model 351 | * 352 | * @param String|DataObject $model the model or class to update 353 | * @param Integer $id The ID of the model to update 354 | * @param HTTPRequest the original request 355 | * 356 | * @return DataObject The updated model 357 | */ 358 | public function updateModel($model, $id, $request) 359 | { 360 | if (is_string($model)) { 361 | $model = DataObject::get_by_id($model, $id); 362 | } 363 | 364 | if (!$model) { 365 | return new RESTfulAPIError(404, 366 | "Record not found." 367 | ); 368 | } 369 | 370 | if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { 371 | return new RESTfulAPIError(403, 372 | "API access denied." 373 | ); 374 | } 375 | 376 | $rawJson = $request->getBody(); 377 | 378 | // Before deserialize hook 379 | if (method_exists($model, 'onBeforeDeserialize')) { 380 | $model->onBeforeDeserialize($rawJson); 381 | } 382 | $model->extend('onBeforeDeserialize', $rawJson); 383 | 384 | $payload = $this->deSerializer->deserialize($rawJson); 385 | if ($payload instanceof RESTfulAPIError) { 386 | return $payload; 387 | } 388 | 389 | // After deserialize hook 390 | if (method_exists($model, 'onAfterDeserialize')) { 391 | $model->onAfterDeserialize($payload); 392 | } 393 | $model->extend('onAfterDeserialize', $payload); 394 | 395 | if ($model && $payload) { 396 | $has_one = Config::inst()->get(get_class($model), 'has_one'); 397 | $has_many = Config::inst()->get(get_class($model), 'has_many'); 398 | $many_many = Config::inst()->get(get_class($model), 'many_many'); 399 | $belongs_many_many = Config::inst()->get(get_class($model), 'belongs_many_many'); 400 | 401 | $many_many_extraFields = array(); 402 | 403 | if (isset($payload['ManyManyExtraFields'])) { 404 | $many_many_extraFields = $payload['ManyManyExtraFields']; 405 | unset($payload['ManyManyExtraFields']); 406 | } 407 | 408 | $hasChanges = false; 409 | $hasRelationChanges = false; 410 | 411 | foreach ($payload as $attribute => $value) { 412 | if (!is_array($value)) { 413 | if (is_array($has_one) && array_key_exists($attribute, $has_one)) { 414 | $relation = $attribute . 'ID'; 415 | $model->$relation = $value; 416 | $hasChanges = true; 417 | } elseif ($model->{$attribute} != $value) { 418 | $model->{$attribute} = $value; 419 | $hasChanges = true; 420 | } 421 | } else { 422 | //has_many, many_many or $belong_many_many 423 | if ((is_array($has_many) && array_key_exists($attribute, $has_many)) 424 | || (is_array($many_many) && array_key_exists($attribute, $many_many)) 425 | || (is_array($belongs_many_many) && array_key_exists($attribute, $belongs_many_many)) 426 | ) { 427 | $hasRelationChanges = true; 428 | $ssList = $model->{$attribute}(); 429 | $ssList->removeAll(); //reset list 430 | foreach ($value as $id) { 431 | // check if there is extraFields 432 | if (array_key_exists($attribute, $many_many_extraFields)) { 433 | if (isset($many_many_extraFields[$attribute][$id])) { 434 | $ssList->add($id, $many_many_extraFields[$attribute][$id]); 435 | continue; 436 | } 437 | } 438 | 439 | $ssList->add($id); 440 | } 441 | } 442 | } 443 | } 444 | 445 | if ($hasChanges || !$model->ID) { 446 | try { 447 | $id = $model->write(false, false, false, $hasRelationChanges); 448 | } catch (ValidationException $exception) { 449 | $error = $exception->getResult(); 450 | $messages = []; 451 | foreach ($error->getMessages() as $message) { 452 | $fieldName = $message['fieldName']; 453 | if ($fieldName) { 454 | $messages[] = "{$fieldName}: {$message['message']}"; 455 | } else { 456 | $messages[] = $message['message']; 457 | } 458 | } 459 | return new RESTfulAPIError(400, 460 | implode("\n", $messages) 461 | ); 462 | } 463 | 464 | if (!$id) { 465 | return new RESTfulAPIError(500, 466 | "Error writting data." 467 | ); 468 | } else { 469 | return DataObject::get_by_id($model->ClassName, $id); 470 | } 471 | } else { 472 | return $model; 473 | } 474 | } else { 475 | return new RESTfulAPIError(400, 476 | "Missing model or payload." 477 | ); 478 | } 479 | } 480 | 481 | /** 482 | * Delete object of Class $model and ID $id 483 | * 484 | * @todo Respond with a 204 status message on success? 485 | * 486 | * @param string $model Model class 487 | * @param integer $id Model ID 488 | * @param HTTPRequest $request Model ID 489 | * @return NULL|array NULL if successful or array with error detail 490 | */ 491 | public function deleteModel($model, $id, HTTPRequest $request) 492 | { 493 | if ($id) { 494 | $object = DataObject::get_by_id($model, $id); 495 | 496 | if ($object) { 497 | if (!RESTfulAPI::api_access_control($object, $request->httpMethod())) { 498 | return new RESTfulAPIError(403, 499 | "API access denied." 500 | ); 501 | } 502 | 503 | $object->delete(); 504 | } else { 505 | return new RESTfulAPIError(404, 506 | "Record not found." 507 | ); 508 | } 509 | } else { 510 | //shouldn't happen but just in case 511 | return new RESTfulAPIError(400, 512 | "Invalid or missing ID. Received '$id'." 513 | ); 514 | } 515 | 516 | return null; 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /src/QueryHandlers/QueryHandler.php: -------------------------------------------------------------------------------- 1 | '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator', 100 | 'authority' => '%$Colymba\RESTfulAPI\PermissionManagers\DefaultPermissionManager', 101 | 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', 102 | 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer', 103 | ); 104 | 105 | /** 106 | * Embedded records setting 107 | * Specify which relation ($has_one, $has_many, $many_many) model data should be embedded into the response 108 | * 109 | * Map of relations to embed for specific record classname 110 | * 'RequestedClass' => array('RelationNameToEmbed', 'Another') 111 | * 112 | * Non embedded response: 113 | * { 114 | * 'member': { 115 | * 'name': 'John', 116 | * 'favourites': [1, 2] 117 | * } 118 | * } 119 | * 120 | * Response with embedded record: 121 | * { 122 | * 'member': { 123 | * 'name': 'John', 124 | * 'favourites': [{ 125 | * 'id': 1, 126 | * 'name': 'Mark' 127 | * },{ 128 | * 'id': 2, 129 | * 'name': 'Maggie' 130 | * }] 131 | * } 132 | * } 133 | * 134 | * @var array 135 | * @config 136 | */ 137 | private static $embedded_records; 138 | 139 | /** 140 | * Cross-Origin Resource Sharing (CORS) 141 | * API settings for cross domain XMLHTTPRequest 142 | * 143 | * Enabled true|false enable/disable CORS 144 | * Allow-Origin String|Array '*' to allow all, 'http://domain.com' to allow single domain, array('http://domain.com', 'http://site.com') to allow multiple domains 145 | * Allow-Headers String '*' to allow all or comma separated list of headers 146 | * Allow-Methods String comma separated list of allowed methods 147 | * Max-Age Integer Preflight/OPTIONS request caching time in seconds (NOTE has no effect if Authentification is enabled => custom header = always preflight) 148 | * 149 | * @var array 150 | * @config 151 | */ 152 | private static $cors = array( 153 | 'Enabled' => true, 154 | 'Allow-Origin' => '*', 155 | 'Allow-Headers' => '*', 156 | 'Allow-Methods' => 'OPTIONS, POST, GET, PUT, DELETE', 157 | 'Max-Age' => 86400, 158 | ); 159 | 160 | /** 161 | * URL handler allowed actions 162 | * 163 | * @var array 164 | */ 165 | private static $allowed_actions = array( 166 | 'index', 167 | 'auth', 168 | 'acl', 169 | ); 170 | 171 | /** 172 | * URL handler definition 173 | * 174 | * @var array 175 | */ 176 | private static $url_handlers = array( 177 | 'auth/$Action' => 'auth', 178 | 'acl/$Action' => 'acl', 179 | '$ModelReference/$ID' => 'index', 180 | ); 181 | 182 | /** 183 | * Returns current query handler instance 184 | * 185 | * @return QueryHandler QueryHandler instance 186 | */ 187 | public function getqueryHandler() 188 | { 189 | return $this->queryHandler; 190 | } 191 | 192 | /** 193 | * Returns current serializer instance 194 | * 195 | * @return Serializer Serializer instance 196 | */ 197 | public function getserializer() 198 | { 199 | return $this->serializer; 200 | } 201 | 202 | /** 203 | * Current RESTfulAPI instance 204 | * 205 | * @var RESTfulAPI 206 | */ 207 | protected static $instance; 208 | 209 | /** 210 | * Constructor.... 211 | */ 212 | public function __construct() 213 | { 214 | parent::__construct(); 215 | 216 | //save current instance in static var 217 | self::$instance = $this; 218 | } 219 | 220 | /** 221 | * Controller inititalisation 222 | * Catches CORS preflight request marked with HTTPMethod 'OPTIONS' 223 | */ 224 | public function init() 225 | { 226 | parent::init(); 227 | 228 | //catch preflight request 229 | if ($this->request->httpMethod() === 'OPTIONS') { 230 | $answer = $this->answer(null, true); 231 | $answer->output(); 232 | exit; 233 | } 234 | } 235 | 236 | /** 237 | * Handles authentications methods 238 | * get response from API Authenticator 239 | * then passes it on to $answer() 240 | * 241 | * @param HTTPRequest $request HTTP request 242 | * @return HTTPResponse 243 | */ 244 | public function auth(HTTPRequest $request) 245 | { 246 | $action = $request->param('Action'); 247 | 248 | if ($this->authenticator) { 249 | $className = get_class($this->authenticator); 250 | $allowedActions = Config::inst()->get($className, 'allowed_actions'); 251 | if (!$allowedActions) { 252 | $allowedActions = array(); 253 | } 254 | 255 | if (in_array($action, $allowedActions)) { 256 | if (method_exists($this->authenticator, $action)) { 257 | $response = $this->authenticator->$action($request); 258 | $response = $this->serializer->serialize($response); 259 | return $this->answer($response); 260 | } else { 261 | //let's be shady here instead 262 | return $this->error(new RESTfulAPIError(403, 263 | "Action '$action' not allowed." 264 | )); 265 | } 266 | } else { 267 | return $this->error(new RESTfulAPIError(403, 268 | "Action '$action' not allowed." 269 | )); 270 | } 271 | } 272 | } 273 | 274 | /** 275 | * Handles Access Control methods 276 | * get response from API PermissionManager 277 | * then passes it on to $answer() 278 | * 279 | * @param HTTPRequest $request HTTP request 280 | * @return HTTPResponse 281 | */ 282 | public function acl(HTTPRequest $request) 283 | { 284 | $action = $request->param('Action'); 285 | 286 | if ($this->authority) { 287 | $className = get_class($this->authority); 288 | $allowedActions = Config::inst()->get($className, 'allowed_actions'); 289 | if (!$allowedActions) { 290 | $allowedActions = array(); 291 | } 292 | 293 | if (in_array($action, $allowedActions)) { 294 | if (method_exists($this->authority, $action)) { 295 | $response = $this->authority->$action($request); 296 | $response = $this->serializer->serialize($response); 297 | return $this->answer($response); 298 | } else { 299 | //let's be shady here instead 300 | return $this->error(new RESTfulAPIError(403, 301 | "Action '$action' not allowed." 302 | )); 303 | } 304 | } else { 305 | return $this->error(new RESTfulAPIError(403, 306 | "Action '$action' not allowed." 307 | )); 308 | } 309 | } 310 | } 311 | 312 | /** 313 | * Main API hub switch 314 | * All requests pass through here and are redirected depending on HTTP verb and params 315 | * 316 | * @todo move authentication check to another methode 317 | * 318 | * @param HTTPRequest $request HTTP request 319 | * @return string json object of the models found 320 | */ 321 | public function index(HTTPRequest $request) 322 | { 323 | //check authentication if enabled 324 | if ($this->authenticator) { 325 | $policy = $this->config()->authentication_policy; 326 | $authALL = $policy === true; 327 | $authMethod = is_array($policy) && in_array($request->httpMethod(), $policy); 328 | 329 | if ($authALL || $authMethod) { 330 | $authResult = $this->authenticator->authenticate($request); 331 | 332 | if ($authResult instanceof RESTfulAPIError) { 333 | //Authentication failed return error to client 334 | return $this->error($authResult); 335 | } 336 | } 337 | } 338 | 339 | //pass control to query handler 340 | $data = $this->queryHandler->handleQuery($request); 341 | //catch + return errors 342 | if ($data instanceof RESTfulAPIError) { 343 | return $this->error($data); 344 | } 345 | 346 | //serialize response 347 | $json = $this->serializer->serialize($data); 348 | //catch + return errors 349 | if ($json instanceof RESTfulAPIError) { 350 | return $this->error($json); 351 | } 352 | 353 | //all is good reply normally 354 | return $this->answer($json); 355 | } 356 | 357 | /** 358 | * Output the API response to client 359 | * then exit. 360 | * 361 | * @param string $json Response body 362 | * @param boolean $corsPreflight Set to true if this is a XHR preflight request answer. CORS shoud be enabled. 363 | * @return HTTPResponse 364 | */ 365 | public function answer($json = null, $corsPreflight = false) 366 | { 367 | $answer = new HTTPResponse(); 368 | 369 | //set response body 370 | if (!$corsPreflight) { 371 | $answer->setBody($json); 372 | } 373 | 374 | //set CORS if needed 375 | $answer = $this->setAnswerCORS($answer); 376 | 377 | $answer->addHeader('Content-Type', $this->serializer->getcontentType()); 378 | 379 | // save controller's response then return/output 380 | $this->response = $answer; 381 | 382 | return $answer; 383 | } 384 | 385 | /** 386 | * Handles formatting and output error message 387 | * then exit. 388 | * 389 | * @param RESTfulAPIError $error Error object to return 390 | * @return HTTPResponse 391 | */ 392 | public function error(RESTfulAPIError $error) 393 | { 394 | $answer = new HTTPResponse(); 395 | 396 | $body = $this->serializer->serialize($error->body); 397 | $answer->setBody($body); 398 | 399 | $answer->setStatusCode($error->code, $error->message); 400 | $answer->addHeader('Content-Type', $this->serializer->getcontentType()); 401 | 402 | $answer = $this->setAnswerCORS($answer); 403 | 404 | // save controller's response then return/output 405 | $this->response = $answer; 406 | 407 | return $answer; 408 | } 409 | 410 | /** 411 | * Apply the proper CORS response heardes 412 | * to an HTTPResponse 413 | * 414 | * @param HTTPResponse $answer The updated response if CORS are neabled 415 | * @return HTTPResponse 416 | */ 417 | private function setAnswerCORS(HTTPResponse $answer) 418 | { 419 | $cors = Config::inst()->get(self::class, 'cors'); 420 | 421 | // skip if CORS is not enabled 422 | if (!$cors['Enabled']) { 423 | return $answer; 424 | } 425 | 426 | //check if Origin is allowed 427 | $allowedOrigin = $cors['Allow-Origin']; 428 | $requestOrigin = $this->request->getHeader('Origin'); 429 | if ($requestOrigin) { 430 | if ($cors['Allow-Origin'] === '*') { 431 | $allowedOrigin = $requestOrigin; 432 | } elseif (is_array($cors['Allow-Origin'])) { 433 | if (in_array($requestOrigin, $cors['Allow-Origin'])) { 434 | $allowedOrigin = $requestOrigin; 435 | } 436 | } 437 | } 438 | $answer->addHeader('Access-Control-Allow-Origin', $allowedOrigin); 439 | 440 | //allowed headers 441 | $allowedHeaders = ''; 442 | $requestHeaders = $this->request->getHeader('Access-Control-Request-Headers'); 443 | if ($cors['Allow-Headers'] === '*') { 444 | $allowedHeaders = $requestHeaders; 445 | } else { 446 | $allowedHeaders = $cors['Allow-Headers']; 447 | } 448 | $answer->addHeader('Access-Control-Allow-Headers', $allowedHeaders); 449 | 450 | //allowed method 451 | $answer->addHeader('Access-Control-Allow-Methods', $cors['Allow-Methods']); 452 | 453 | //max age 454 | $answer->addHeader('Access-Control-Max-Age', $cors['Max-Age']); 455 | 456 | return $answer; 457 | } 458 | 459 | /** 460 | * Checks a class or model api access 461 | * depending on access_control_policy and the provided model. 462 | * - 1st config check 463 | * - 2nd permission check if config access passes 464 | * 465 | * @param string|DataObject $model Model's classname or DataObject 466 | * @param string $httpMethod API request HTTP method 467 | * @return boolean true if access is granted, false otherwise 468 | */ 469 | public static function api_access_control($model, $httpMethod = 'GET') 470 | { 471 | $policy = self::config()->access_control_policy; 472 | if ($policy === false) { 473 | return true; 474 | } // if access control is disabled, skip 475 | else { 476 | $policy = constant('self::' . $policy); 477 | } 478 | 479 | if ($policy === self::ACL_CHECK_MODEL_ONLY) { 480 | $access = true; 481 | } else { 482 | $access = false; 483 | } 484 | 485 | if ($policy === self::ACL_CHECK_CONFIG_ONLY || $policy === self::ACL_CHECK_CONFIG_AND_MODEL) { 486 | if (!is_string($model)) { 487 | $className = get_class($model); 488 | } else { 489 | $className = $model; 490 | } 491 | 492 | $access = self::api_access_config_check($className, $httpMethod); 493 | } 494 | 495 | if ($policy === self::ACL_CHECK_MODEL_ONLY || $policy === self::ACL_CHECK_CONFIG_AND_MODEL) { 496 | if ($access) { 497 | $access = self::model_permission_check($model, $httpMethod); 498 | } 499 | } 500 | 501 | return $access; 502 | } 503 | 504 | /** 505 | * Checks a model's api_access config. 506 | * api_access config can be: 507 | * - unset|false, access is always denied 508 | * - true, access is always granted 509 | * - comma separated list of allowed HTTP methods 510 | * 511 | * @param string $className Model's classname 512 | * @param string $httpMethod API request HTTP method 513 | * @return boolean true if access is granted, false otherwise 514 | */ 515 | private static function api_access_config_check($className, $httpMethod = 'GET') 516 | { 517 | $access = false; 518 | $api_access = singleton($className)->stat('api_access'); 519 | 520 | if (is_string($api_access)) { 521 | $api_access = explode(',', strtoupper($api_access)); 522 | if (in_array($httpMethod, $api_access)) { 523 | $access = true; 524 | } else { 525 | $access = false; 526 | } 527 | } elseif ($api_access === true) { 528 | $access = true; 529 | } 530 | 531 | return $access; 532 | } 533 | 534 | /** 535 | * Checks a Model's permission for the currently 536 | * authenticated user via the Permission Manager dependency. 537 | * 538 | * For permissions to actually be checked, this means the RESTfulAPI 539 | * must have both authenticator and authority dependencies defined. 540 | * 541 | * If the authenticator component does not return an instance of the Member 542 | * null will be passed to the authority component. 543 | * 544 | * This default to true. 545 | * 546 | * @param string|DataObject $model Model's classname or DataObject to check permission for 547 | * @param string $httpMethod API request HTTP method 548 | * @return boolean true if access is granted, false otherwise 549 | */ 550 | private static function model_permission_check($model, $httpMethod = 'GET') 551 | { 552 | $access = true; 553 | $apiInstance = self::$instance; 554 | 555 | if ($apiInstance->authenticator && $apiInstance->authority) { 556 | $request = $apiInstance->request; 557 | $member = $apiInstance->authenticator->getOwner($request); 558 | 559 | if (!$member instanceof Member) { 560 | $member = null; 561 | } 562 | 563 | $access = $apiInstance->authority->checkPermission($model, $member, $httpMethod); 564 | if (!is_bool($access)) { 565 | $access = true; 566 | } 567 | } 568 | 569 | return $access; 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /src/RESTfulAPIError.php: -------------------------------------------------------------------------------- 1 | code = $code; 50 | $this->message = $message; 51 | 52 | if ($body !== null) { 53 | $this->body = $body; 54 | } else { 55 | $this->body = array( 56 | 'code' => $code, 57 | 'message' => $message, 58 | ); 59 | } 60 | } 61 | 62 | /** 63 | * Check for the latest JSON parsing error 64 | * and return the message if any 65 | * 66 | * More available for PHP >= 5.3.3 67 | * http://www.php.net/manual/en/function.json-last-error.php 68 | * 69 | * @return false|string Returns false if no error or a string with the error detail. 70 | */ 71 | public static function get_json_error() 72 | { 73 | $error = 'JSON - '; 74 | 75 | switch (json_last_error()) { 76 | case JSON_ERROR_NONE: 77 | $error = false; 78 | break; 79 | 80 | case JSON_ERROR_DEPTH: 81 | $error .= 'The maximum stack depth has been exceeded.'; 82 | break; 83 | 84 | case JSON_ERROR_STATE_MISMATCH: 85 | $error .= 'Invalid or malformed JSON.'; 86 | break; 87 | 88 | case JSON_ERROR_CTRL_CHAR: 89 | $error .= 'Control character error, possibly incorrectly encoded.'; 90 | break; 91 | 92 | case JSON_ERROR_SYNTAX: 93 | $error .= 'Syntax error.'; 94 | break; 95 | 96 | default: 97 | $error .= 'Unknown error (' . json_last_error() . ').'; 98 | break; 99 | } 100 | 101 | return $error; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Serializers/DeSerializer.php: -------------------------------------------------------------------------------- 1 | unformatPayloadData($data); 48 | } else { 49 | return new RESTfulAPIError(400, 50 | "No data received." 51 | ); 52 | } 53 | 54 | return $data; 55 | } 56 | 57 | /** 58 | * Process payload data from client 59 | * and unformats columns/values recursively 60 | * 61 | * @param array $data Payload data (decoded JSON) 62 | * @return array Paylaod data with all keys/values unformatted 63 | */ 64 | protected function unformatPayloadData(array $data) 65 | { 66 | $unformattedData = array(); 67 | 68 | foreach ($data as $key => $value) { 69 | $newKey = $this->deserializeColumnName($key); 70 | 71 | if (is_array($value)) { 72 | $newValue = $this->unformatPayloadData($value); 73 | } else { 74 | $newValue = $value; 75 | } 76 | 77 | $unformattedData[$newKey] = $newValue; 78 | } 79 | 80 | return $unformattedData; 81 | } 82 | 83 | /** 84 | * Format a ClassName or Field name sent by client API 85 | * to be used by SilverStripe 86 | * 87 | * @param string $name ClassName of Field name 88 | * @return string Formatted name 89 | */ 90 | public function unformatName($name) 91 | { 92 | $class = ucfirst($name); 93 | if (ClassInfo::exists($class)) { 94 | return $class; 95 | } else { 96 | return $name; 97 | } 98 | } 99 | 100 | /** 101 | * Format a DB Column name or Field name 102 | * sent from client API to be used by SilverStripe 103 | * 104 | * @param string $name Field name 105 | * @return string Formatted name 106 | */ 107 | private function deserializeColumnName($name) 108 | { 109 | return $name; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Serializers/DefaultSerializer.php: -------------------------------------------------------------------------------- 1 | contentType; 43 | } 44 | 45 | /** 46 | * Stores the current $embedded_records @config 47 | * Config set on {@link RESTfulAPI} 48 | * 49 | * @var array 50 | */ 51 | protected $embeddedRecords; 52 | 53 | /** 54 | * Construct and set current config 55 | */ 56 | public function __construct() 57 | { 58 | $embedded_records = Config::inst()->get('colymba\\RESTfulAPI\\RESTfulAPI', 'embedded_records'); 59 | if (is_array($embedded_records)) { 60 | $this->embeddedRecords = $embedded_records; 61 | } else { 62 | $this->embeddedRecords = array(); 63 | } 64 | } 65 | 66 | /** 67 | * Convert data into a JSON string 68 | * 69 | * @param mixed $data Data to convert 70 | * @return string JSON data 71 | */ 72 | protected function jsonify($data) 73 | { 74 | // JSON_NUMERIC_CHECK removes leading zeros 75 | // which is an issue in cases like postcode e.g. 00160 76 | // see https://bugs.php.net/bug.php?id=64695 77 | $json = json_encode($data); 78 | 79 | //catch JSON parsing error 80 | $error = RESTfulAPIError::get_json_error(); 81 | if ($error !== false) { 82 | return new RESTfulAPIError(400, $error); 83 | } 84 | 85 | return $json; 86 | } 87 | 88 | /** 89 | * Convert raw data (DataObject or DataList) to JSON 90 | * ready to be consumed by the client API 91 | * 92 | * @param mixed $data Data to serialize 93 | * @return string JSON representation of data 94 | */ 95 | public function serialize($data) 96 | { 97 | $json = ''; 98 | $formattedData = null; 99 | 100 | if ($data instanceof DataObject) { 101 | $formattedData = $this->formatDataObject($data); 102 | } elseif ($data instanceof DataList) { 103 | $formattedData = $this->formatDataList($data); 104 | } 105 | 106 | if ($formattedData !== null) { 107 | $json = $this->jsonify($formattedData); 108 | } else { 109 | //fallback: convert non array to object then encode 110 | if (!is_array($data)) { 111 | $data = (object) $data; 112 | } 113 | $json = $this->jsonify($data); 114 | } 115 | 116 | return $json; 117 | } 118 | 119 | /** 120 | * Format a DataObject keys and values 121 | * ready to be turned into JSON 122 | * 123 | * @param DataObject $dataObject The data object to format 124 | * @return array|null The formatted array map representation of the DataObject or null 125 | * is permission denied 126 | */ 127 | protected function formatDataObject(DataObject $dataObject) 128 | { 129 | // api access control 130 | if (!RESTfulAPI::api_access_control($dataObject, 'GET')) { 131 | return null; 132 | } 133 | 134 | if (method_exists($dataObject, 'onBeforeSerialize')) { 135 | $dataObject->onBeforeSerialize(); 136 | } 137 | $dataObject->extend('onBeforeSerialize'); 138 | 139 | // setup 140 | $formattedDataObjectMap = array(); 141 | 142 | // get DataObject config 143 | $class = get_class($dataObject); 144 | $db = Config::inst()->get($class, 'db'); 145 | $has_one = Config::inst()->get($class, 'has_one'); 146 | $has_many = Config::inst()->get($class, 'has_many'); 147 | $many_many = Config::inst()->get($class, 'many_many'); 148 | $belongs_many_many = Config::inst()->get($class, 'belongs_many_many'); 149 | 150 | // Get a possibly defined list of "api_fields" for this DataObject. If defined, they will be the only fields 151 | // for this DataObject that will be returned, including related models. 152 | $apiFields = (array) Config::inst()->get($class, 'api_fields'); 153 | 154 | //$many_many_extraFields = $dataObject->many_many_extraFields(); 155 | $many_many_extraFields = $dataObject->stat('many_many_extraFields'); 156 | 157 | // setup ID (not included in $db!!) 158 | $serializedColumnName = $this->serializeColumnName('ID'); 159 | $formattedDataObjectMap[$serializedColumnName] = $dataObject->getField('ID'); 160 | 161 | // iterate over simple DB fields 162 | if (!$db) { 163 | $db = array(); 164 | } 165 | 166 | foreach ($db as $columnName => $fieldClassName) { 167 | // Check whether this field has been specified as allowed via api_fields 168 | if (!empty($apiFields) && !in_array($columnName, $apiFields)) { 169 | continue; 170 | } 171 | 172 | $serializedColumnName = $this->serializeColumnName($columnName); 173 | $formattedDataObjectMap[$serializedColumnName] = $dataObject->getField($columnName); 174 | } 175 | 176 | // iterate over has_one relations 177 | if (!$has_one) { 178 | $has_one = array(); 179 | } 180 | foreach ($has_one as $columnName => $fieldClassName) { 181 | // Skip if api_fields is set for the parent, and this column is not in it 182 | if (!empty($apiFields) && !in_array($columnName, $apiFields)) { 183 | continue; 184 | } 185 | 186 | $serializedColumnName = $this->serializeColumnName($columnName); 187 | 188 | // convert foreign ID to integer 189 | $relationID = intVal($dataObject->{$columnName . 'ID'}); 190 | // skip empty relations 191 | if ($relationID === 0) { 192 | continue; 193 | } 194 | 195 | // check if this should be embedded 196 | if ($this->isEmbeddable($dataObject->ClassName, $columnName)) { 197 | // get the relation's record ready to embed 198 | $embedData = $this->getEmbedData($dataObject, $columnName); 199 | // embed the data if any 200 | if ($embedData !== null) { 201 | $formattedDataObjectMap[$serializedColumnName] = $embedData; 202 | } 203 | } else { 204 | // save foreign ID 205 | $formattedDataObjectMap[$serializedColumnName] = $relationID; 206 | } 207 | } 208 | 209 | // combine defined '_many' relations into 1 array 210 | $many_relations = array(); 211 | if (is_array($has_many)) { 212 | $many_relations = array_merge($many_relations, $has_many); 213 | } 214 | if (is_array($many_many)) { 215 | $many_relations = array_merge($many_relations, $many_many); 216 | } 217 | if (is_array($belongs_many_many)) { 218 | $many_relations = array_merge($many_relations, $belongs_many_many); 219 | } 220 | 221 | // iterate '_many' relations 222 | foreach ($many_relations as $relationName => $relationClassname) { 223 | // Skip if api_fields is set for the parent, and this column is not in it 224 | if (!empty($apiFields) && !in_array($relationName, $apiFields)) { 225 | continue; 226 | } 227 | 228 | //get the DataList for this realtion's name 229 | $dataList = $dataObject->{$relationName}(); 230 | 231 | //if there actually are objects in the relation 232 | if ($dataList->count()) { 233 | // check if this relation should be embedded 234 | if ($this->isEmbeddable($dataObject->ClassName, $relationName)) { 235 | // get the relation's record(s) ready to embed 236 | $embedData = $this->getEmbedData($dataObject, $relationName); 237 | // embed the data if any 238 | if ($embedData !== null) { 239 | $serializedColumnName = $this->serializeColumnName($relationName); 240 | $formattedDataObjectMap[$serializedColumnName] = $embedData; 241 | } 242 | } else { 243 | // set column value to ID list 244 | $idList = $dataList->map('ID', 'ID')->keys(); 245 | 246 | $serializedColumnName = $this->serializeColumnName($relationName); 247 | $formattedDataObjectMap[$serializedColumnName] = $idList; 248 | } 249 | } 250 | } 251 | 252 | if ($many_many_extraFields) { 253 | $extraFieldsData = array(); 254 | 255 | // loop through extra fields config 256 | foreach ($many_many_extraFields as $relation => $fields) { 257 | $manyManyDataObjects = $dataObject->$relation(); 258 | $relationData = array(); 259 | 260 | // get the extra data for each object in the relation 261 | foreach ($manyManyDataObjects as $manyManyDataObject) { 262 | $data = $manyManyDataObjects->getExtraData($relation, $manyManyDataObject->ID); 263 | 264 | // format data 265 | foreach ($data as $key => $value) { 266 | // clear empty data 267 | if (!$value) { 268 | unset($data[$key]); 269 | continue; 270 | } 271 | 272 | $newKey = $this->serializeColumnName($key); 273 | if ($newKey != $key) { 274 | unset($data[$key]); 275 | $data[$newKey] = $value; 276 | } 277 | } 278 | 279 | // store if there is any real data 280 | if ($data) { 281 | $relationData[$manyManyDataObject->ID] = $data; 282 | } 283 | } 284 | 285 | // add individual DO extra data to the relation's extra data 286 | if ($relationData) { 287 | $key = $this->serializeColumnName($relation); 288 | $extraFieldsData[$key] = $relationData; 289 | } 290 | } 291 | 292 | // save the extrafields data 293 | if ($extraFieldsData) { 294 | $key = $this->serializeColumnName('ManyManyExtraFields'); 295 | $formattedDataObjectMap[$key] = $extraFieldsData; 296 | } 297 | } 298 | 299 | if (method_exists($dataObject, 'onAfterSerialize')) { 300 | $dataObject->onAfterSerialize($formattedDataObjectMap); 301 | } 302 | $dataObject->extend('onAfterSerialize', $formattedDataObjectMap); 303 | 304 | return $formattedDataObjectMap; 305 | } 306 | 307 | /** 308 | * Format a DataList into a formatted array ready to be turned into JSON 309 | * 310 | * @param DataList $dataList The DataList to format 311 | * @return array The formatted array representation of the DataList 312 | */ 313 | protected function formatDataList(DataList $dataList) 314 | { 315 | $formattedDataListMap = array(); 316 | 317 | foreach ($dataList as $dataObject) { 318 | $formattedDataObjectMap = $this->formatDataObject($dataObject); 319 | if ($formattedDataObjectMap) { 320 | array_push($formattedDataListMap, $formattedDataObjectMap); 321 | } 322 | } 323 | 324 | return $formattedDataListMap; 325 | } 326 | 327 | /** 328 | * Format a SilverStripe ClassName or Field name to be used by the client API 329 | * 330 | * @param string $name ClassName of DBField name 331 | * @return string Formatted name 332 | */ 333 | public function formatName($name) 334 | { 335 | return $name; 336 | } 337 | 338 | /** 339 | * Format a DB Column name or Field name to be used by the client API 340 | * 341 | * @param string $name Field name 342 | * @return string Formatted name 343 | */ 344 | protected function serializeColumnName($name) 345 | { 346 | return $name; 347 | } 348 | 349 | /** 350 | * Returns a DataObject relation's data formatted and ready to embed. 351 | * 352 | * @param DataObject $record The DataObject to get the data from 353 | * @param string $relationName The name of the relation 354 | * @return array|null Formatted DataObject or RelationList ready to embed or null if nothing to embed 355 | */ 356 | protected function getEmbedData(DataObject $record, $relationName) 357 | { 358 | if ($record->hasMethod($relationName)) { 359 | $relationData = $record->$relationName(); 360 | if ($relationData instanceof RelationList) { 361 | return $this->formatDataList($relationData); 362 | } else { 363 | return $this->formatDataObject($relationData); 364 | } 365 | } 366 | 367 | return null; 368 | } 369 | 370 | /** 371 | * Checks if a speicific model's relation should have its records embedded. 372 | * 373 | * @param string $model Model's classname 374 | * @param string $relation Relation name 375 | * @return boolean Trus if the relation should be embedded 376 | */ 377 | protected function isEmbeddable($model, $relation) 378 | { 379 | if (array_key_exists($model, $this->embeddedRecords)) { 380 | return is_array($this->embeddedRecords[$model]) && in_array($relation, $this->embeddedRecords[$model]); 381 | } 382 | 383 | return false; 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /src/Serializers/Serializer.php: -------------------------------------------------------------------------------- 1 | array( 39 | '/(s)tatus$/i' => '\1\2tatuses', 40 | '/(quiz)$/i' => '\1zes', 41 | '/^(ox)$/i' => '\1\2en', 42 | '/([m|l])ouse$/i' => '\1ice', 43 | '/(matr|vert|ind)(ix|ex)$/i' => '\1ices', 44 | '/(x|ch|ss|sh)$/i' => '\1es', 45 | '/([^aeiouy]|qu)y$/i' => '\1ies', 46 | '/(hive)$/i' => '\1s', 47 | '/(?:([^f])fe|([lr])f)$/i' => '\1\2ves', 48 | '/sis$/i' => 'ses', 49 | '/([ti])um$/i' => '\1a', 50 | '/(p)erson$/i' => '\1eople', 51 | '/(m)an$/i' => '\1en', 52 | '/(c)hild$/i' => '\1hildren', 53 | '/(buffal|tomat)o$/i' => '\1\2oes', 54 | '/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin|vir)us$/i' => '\1i', 55 | '/us$/i' => 'uses', 56 | '/(alias)$/i' => '\1es', 57 | '/(ax|cris|test)is$/i' => '\1es', 58 | '/s$/' => 's', 59 | '/^$/' => '', 60 | '/$/' => 's', 61 | ), 62 | 'uninflected' => array( 63 | '.*[nrlm]ese', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', 'people', 64 | ), 65 | 'irregular' => array( 66 | 'atlas' => 'atlases', 67 | 'beef' => 'beefs', 68 | 'brother' => 'brothers', 69 | 'cafe' => 'cafes', 70 | 'child' => 'children', 71 | 'cookie' => 'cookies', 72 | 'corpus' => 'corpuses', 73 | 'cow' => 'cows', 74 | 'ganglion' => 'ganglions', 75 | 'genie' => 'genies', 76 | 'genus' => 'genera', 77 | 'graffito' => 'graffiti', 78 | 'hoof' => 'hoofs', 79 | 'loaf' => 'loaves', 80 | 'man' => 'men', 81 | 'money' => 'monies', 82 | 'mongoose' => 'mongooses', 83 | 'move' => 'moves', 84 | 'mythos' => 'mythoi', 85 | 'niche' => 'niches', 86 | 'numen' => 'numina', 87 | 'occiput' => 'occiputs', 88 | 'octopus' => 'octopuses', 89 | 'opus' => 'opuses', 90 | 'ox' => 'oxen', 91 | 'penis' => 'penises', 92 | 'person' => 'people', 93 | 'sex' => 'sexes', 94 | 'soliloquy' => 'soliloquies', 95 | 'testis' => 'testes', 96 | 'trilby' => 'trilbys', 97 | 'turf' => 'turfs', 98 | ), 99 | ); 100 | 101 | /** 102 | * Singular inflector rules 103 | * 104 | * @var array 105 | */ 106 | protected static $_singular = array( 107 | 'rules' => array( 108 | '/(s)tatuses$/i' => '\1\2tatus', 109 | '/^(.*)(menu)s$/i' => '\1\2', 110 | '/(quiz)zes$/i' => '\\1', 111 | '/(matr)ices$/i' => '\1ix', 112 | '/(vert|ind)ices$/i' => '\1ex', 113 | '/^(ox)en/i' => '\1', 114 | '/(alias)(es)*$/i' => '\1', 115 | '/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin|viri?)i$/i' => '\1us', 116 | '/([ftw]ax)es/i' => '\1', 117 | '/(cris|ax|test)es$/i' => '\1is', 118 | '/(shoe|slave)s$/i' => '\1', 119 | '/(o)es$/i' => '\1', 120 | '/ouses$/' => 'ouse', 121 | '/([^a])uses$/' => '\1us', 122 | '/([m|l])ice$/i' => '\1ouse', 123 | '/(x|ch|ss|sh)es$/i' => '\1', 124 | '/(m)ovies$/i' => '\1\2ovie', 125 | '/(s)eries$/i' => '\1\2eries', 126 | '/([^aeiouy]|qu)ies$/i' => '\1y', 127 | '/([lr])ves$/i' => '\1f', 128 | '/(tive)s$/i' => '\1', 129 | '/(hive)s$/i' => '\1', 130 | '/(drive)s$/i' => '\1', 131 | '/([^fo])ves$/i' => '\1fe', 132 | '/(^analy)ses$/i' => '\1sis', 133 | '/(analy|diagno|^ba|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i' => '\1\2sis', 134 | '/([ti])a$/i' => '\1um', 135 | '/(p)eople$/i' => '\1\2erson', 136 | '/(m)en$/i' => '\1an', 137 | '/(c)hildren$/i' => '\1\2hild', 138 | '/(n)ews$/i' => '\1\2ews', 139 | '/eaus$/' => 'eau', 140 | '/^(.*us)$/' => '\\1', 141 | '/s$/i' => '', 142 | ), 143 | 'uninflected' => array( 144 | '.*[nrlm]ese', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', '.*ss', 145 | ), 146 | 'irregular' => array( 147 | 'foes' => 'foe', 148 | 'waves' => 'wave', 149 | 'curves' => 'curve', 150 | ), 151 | ); 152 | 153 | /** 154 | * Words that should not be inflected 155 | * 156 | * @var array 157 | */ 158 | protected static $_uninflected = array( 159 | 'Amoyese', 'bison', 'Borghese', 'bream', 'breeches', 'britches', 'buffalo', 'cantus', 160 | 'carp', 'chassis', 'clippers', 'cod', 'coitus', 'Congoese', 'contretemps', 'corps', 161 | 'debris', 'diabetes', 'djinn', 'eland', 'elk', 'equipment', 'Faroese', 'flounder', 162 | 'Foochowese', 'gallows', 'Genevese', 'Genoese', 'Gilbertese', 'graffiti', 163 | 'headquarters', 'herpes', 'hijinks', 'Hottentotese', 'information', 'innings', 164 | 'jackanapes', 'Kiplingese', 'Kongoese', 'Lucchese', 'mackerel', 'Maltese', '.*?media', 165 | 'mews', 'moose', 'mumps', 'Nankingese', 'news', 'nexus', 'Niasese', 166 | 'Pekingese', 'Piedmontese', 'pincers', 'Pistoiese', 'pliers', 'Portuguese', 167 | 'proceedings', 'rabies', 'rice', 'rhinoceros', 'salmon', 'Sarawakese', 'scissors', 168 | 'sea[- ]bass', 'series', 'Shavese', 'shears', 'siemens', 'species', 'swine', 'testes', 169 | 'trousers', 'trout', 'tuna', 'Vermontese', 'Wenchowese', 'whiting', 'wildebeest', 170 | 'Yengeese', 171 | ); 172 | 173 | /** 174 | * Default map of accented and special characters to ASCII characters 175 | * 176 | * @var array 177 | */ 178 | protected static $_transliteration = array( 179 | '/ä|æ|ǽ/' => 'ae', 180 | '/ö|œ/' => 'oe', 181 | '/ü/' => 'ue', 182 | '/Ä/' => 'Ae', 183 | '/Ü/' => 'Ue', 184 | '/Ö/' => 'Oe', 185 | '/À|Á|Â|Ã|Å|Ǻ|Ā|Ă|Ą|Ǎ/' => 'A', 186 | '/à|á|â|ã|å|ǻ|ā|ă|ą|ǎ|ª/' => 'a', 187 | '/Ç|Ć|Ĉ|Ċ|Č/' => 'C', 188 | '/ç|ć|ĉ|ċ|č/' => 'c', 189 | '/Ð|Ď|Đ/' => 'D', 190 | '/ð|ď|đ/' => 'd', 191 | '/È|É|Ê|Ë|Ē|Ĕ|Ė|Ę|Ě/' => 'E', 192 | '/è|é|ê|ë|ē|ĕ|ė|ę|ě/' => 'e', 193 | '/Ĝ|Ğ|Ġ|Ģ/' => 'G', 194 | '/ĝ|ğ|ġ|ģ/' => 'g', 195 | '/Ĥ|Ħ/' => 'H', 196 | '/ĥ|ħ/' => 'h', 197 | '/Ì|Í|Î|Ï|Ĩ|Ī|Ĭ|Ǐ|Į|İ/' => 'I', 198 | '/ì|í|î|ï|ĩ|ī|ĭ|ǐ|į|ı/' => 'i', 199 | '/Ĵ/' => 'J', 200 | '/ĵ/' => 'j', 201 | '/Ķ/' => 'K', 202 | '/ķ/' => 'k', 203 | '/Ĺ|Ļ|Ľ|Ŀ|Ł/' => 'L', 204 | '/ĺ|ļ|ľ|ŀ|ł/' => 'l', 205 | '/Ñ|Ń|Ņ|Ň/' => 'N', 206 | '/ñ|ń|ņ|ň|ʼn/' => 'n', 207 | '/Ò|Ó|Ô|Õ|Ō|Ŏ|Ǒ|Ő|Ơ|Ø|Ǿ/' => 'O', 208 | '/ò|ó|ô|õ|ō|ŏ|ǒ|ő|ơ|ø|ǿ|º/' => 'o', 209 | '/Ŕ|Ŗ|Ř/' => 'R', 210 | '/ŕ|ŗ|ř/' => 'r', 211 | '/Ś|Ŝ|Ş|Š/' => 'S', 212 | '/ś|ŝ|ş|š|ſ/' => 's', 213 | '/Ţ|Ť|Ŧ/' => 'T', 214 | '/ţ|ť|ŧ/' => 't', 215 | '/Ù|Ú|Û|Ũ|Ū|Ŭ|Ů|Ű|Ų|Ư|Ǔ|Ǖ|Ǘ|Ǚ|Ǜ/' => 'U', 216 | '/ù|ú|û|ũ|ū|ŭ|ů|ű|ų|ư|ǔ|ǖ|ǘ|ǚ|ǜ/' => 'u', 217 | '/Ý|Ÿ|Ŷ/' => 'Y', 218 | '/ý|ÿ|ŷ/' => 'y', 219 | '/Ŵ/' => 'W', 220 | '/ŵ/' => 'w', 221 | '/Ź|Ż|Ž/' => 'Z', 222 | '/ź|ż|ž/' => 'z', 223 | '/Æ|Ǽ/' => 'AE', 224 | '/ß/' => 'ss', 225 | '/IJ/' => 'IJ', 226 | '/ij/' => 'ij', 227 | '/Œ/' => 'OE', 228 | '/ƒ/' => 'f', 229 | ); 230 | 231 | /** 232 | * Method cache array. 233 | * 234 | * @var array 235 | */ 236 | protected static $_cache = array(); 237 | 238 | /** 239 | * The initial state of Inflector so reset() works. 240 | * 241 | * @var array 242 | */ 243 | protected static $_initialState = array(); 244 | 245 | /** 246 | * Cache inflected values, and return if already available 247 | * 248 | * @param string $type Inflection type 249 | * @param string $key Original value 250 | * @param string $value Inflected value 251 | * @return string Inflected value, from cache 252 | */ 253 | protected static function _cache($type, $key, $value = false) 254 | { 255 | $key = '_' . $key; 256 | $type = '_' . $type; 257 | if ($value !== false) { 258 | self::$_cache[$type][$key] = $value; 259 | return $value; 260 | } 261 | if (!isset(self::$_cache[$type][$key])) { 262 | return false; 263 | } 264 | return self::$_cache[$type][$key]; 265 | } 266 | 267 | /** 268 | * Clears Inflectors inflected value caches. And resets the inflection 269 | * rules to the initial values. 270 | * 271 | * @return void 272 | */ 273 | public static function reset() 274 | { 275 | if (empty(self::$_initialState)) { 276 | self::$_initialState = get_class_vars('Inflector'); 277 | return; 278 | } 279 | foreach (self::$_initialState as $key => $val) { 280 | if ($key !== '_initialState') { 281 | self::${$key} = $val; 282 | } 283 | } 284 | } 285 | 286 | /** 287 | * Adds custom inflection $rules, of either 'plural', 'singular' or 'transliteration' $type. 288 | * 289 | * ### Usage: 290 | * 291 | * {{{ 292 | * Inflector::rules('plural', array('/^(inflect)or$/i' => '\1ables')); 293 | * Inflector::rules('plural', array( 294 | * 'rules' => array('/^(inflect)ors$/i' => '\1ables'), 295 | * 'uninflected' => array('dontinflectme'), 296 | * 'irregular' => array('red' => 'redlings') 297 | * )); 298 | * Inflector::rules('transliteration', array('/å/' => 'aa')); 299 | * }}} 300 | * 301 | * @param string $type The type of inflection, either 'plural', 'singular' or 'transliteration' 302 | * @param array $rules Array of rules to be added. 303 | * @param boolean $reset If true, will unset default inflections for all 304 | * new rules that are being defined in $rules. 305 | * @return void 306 | */ 307 | public static function rules($type, $rules, $reset = false) 308 | { 309 | $var = '_' . $type; 310 | 311 | switch ($type) { 312 | case 'transliteration': 313 | if ($reset) { 314 | self::$_transliteration = $rules; 315 | } else { 316 | self::$_transliteration = $rules + self::$_transliteration; 317 | } 318 | break; 319 | 320 | default: 321 | foreach ($rules as $rule => $pattern) { 322 | if (is_array($pattern)) { 323 | if ($reset) { 324 | self::${$var}[$rule] = $pattern; 325 | } else { 326 | if ($rule === 'uninflected') { 327 | self::${$var}[$rule] = array_merge($pattern, self::${$var}[$rule]); 328 | } else { 329 | self::${$var}[$rule] = $pattern + self::${$var}[$rule]; 330 | } 331 | } 332 | unset($rules[$rule], self::${$var}['cache' . ucfirst($rule)]); 333 | if (isset(self::${$var}['merged'][$rule])) { 334 | unset(self::${$var}['merged'][$rule]); 335 | } 336 | if ($type === 'plural') { 337 | self::$_cache['pluralize'] = self::$_cache['tableize'] = array(); 338 | } elseif ($type === 'singular') { 339 | self::$_cache['singularize'] = array(); 340 | } 341 | } 342 | } 343 | self::${$var}['rules'] = $rules + self::${$var}['rules']; 344 | break; 345 | } 346 | } 347 | 348 | /** 349 | * Return $word in plural form. 350 | * 351 | * @param string $word Word in singular 352 | * @return string Word in plural 353 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::pluralize 354 | */ 355 | public static function pluralize($word) 356 | { 357 | if (isset(self::$_cache['pluralize'][$word])) { 358 | return self::$_cache['pluralize'][$word]; 359 | } 360 | 361 | if (!isset(self::$_plural['merged']['irregular'])) { 362 | self::$_plural['merged']['irregular'] = self::$_plural['irregular']; 363 | } 364 | 365 | if (!isset(self::$_plural['merged']['uninflected'])) { 366 | self::$_plural['merged']['uninflected'] = array_merge(self::$_plural['uninflected'], self::$_uninflected); 367 | } 368 | 369 | if (!isset(self::$_plural['cacheUninflected']) || !isset(self::$_plural['cacheIrregular'])) { 370 | self::$_plural['cacheUninflected'] = '(?:' . implode('|', self::$_plural['merged']['uninflected']) . ')'; 371 | self::$_plural['cacheIrregular'] = '(?:' . implode('|', array_keys(self::$_plural['merged']['irregular'])) . ')'; 372 | } 373 | 374 | if (preg_match('/(.*)\\b(' . self::$_plural['cacheIrregular'] . ')$/i', $word, $regs)) { 375 | self::$_cache['pluralize'][$word] = $regs[1] . substr($word, 0, 1) . substr(self::$_plural['merged']['irregular'][strtolower($regs[2])], 1); 376 | return self::$_cache['pluralize'][$word]; 377 | } 378 | 379 | if (preg_match('/^(' . self::$_plural['cacheUninflected'] . ')$/i', $word, $regs)) { 380 | self::$_cache['pluralize'][$word] = $word; 381 | return $word; 382 | } 383 | 384 | foreach (self::$_plural['rules'] as $rule => $replacement) { 385 | if (preg_match($rule, $word)) { 386 | self::$_cache['pluralize'][$word] = preg_replace($rule, $replacement, $word); 387 | return self::$_cache['pluralize'][$word]; 388 | } 389 | } 390 | } 391 | 392 | /** 393 | * Return $word in singular form. 394 | * 395 | * @param string $word Word in plural 396 | * @return string Word in singular 397 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::singularize 398 | */ 399 | public static function singularize($word) 400 | { 401 | if (isset(self::$_cache['singularize'][$word])) { 402 | return self::$_cache['singularize'][$word]; 403 | } 404 | 405 | if (!isset(self::$_singular['merged']['uninflected'])) { 406 | self::$_singular['merged']['uninflected'] = array_merge( 407 | self::$_singular['uninflected'], 408 | self::$_uninflected 409 | ); 410 | } 411 | 412 | if (!isset(self::$_singular['merged']['irregular'])) { 413 | self::$_singular['merged']['irregular'] = array_merge( 414 | self::$_singular['irregular'], 415 | array_flip(self::$_plural['irregular']) 416 | ); 417 | } 418 | 419 | if (!isset(self::$_singular['cacheUninflected']) || !isset(self::$_singular['cacheIrregular'])) { 420 | self::$_singular['cacheUninflected'] = '(?:' . implode('|', self::$_singular['merged']['uninflected']) . ')'; 421 | self::$_singular['cacheIrregular'] = '(?:' . implode('|', array_keys(self::$_singular['merged']['irregular'])) . ')'; 422 | } 423 | 424 | if (preg_match('/(.*)\\b(' . self::$_singular['cacheIrregular'] . ')$/i', $word, $regs)) { 425 | self::$_cache['singularize'][$word] = $regs[1] . substr($word, 0, 1) . substr(self::$_singular['merged']['irregular'][strtolower($regs[2])], 1); 426 | return self::$_cache['singularize'][$word]; 427 | } 428 | 429 | if (preg_match('/^(' . self::$_singular['cacheUninflected'] . ')$/i', $word, $regs)) { 430 | self::$_cache['singularize'][$word] = $word; 431 | return $word; 432 | } 433 | 434 | foreach (self::$_singular['rules'] as $rule => $replacement) { 435 | if (preg_match($rule, $word)) { 436 | self::$_cache['singularize'][$word] = preg_replace($rule, $replacement, $word); 437 | return self::$_cache['singularize'][$word]; 438 | } 439 | } 440 | self::$_cache['singularize'][$word] = $word; 441 | return $word; 442 | } 443 | 444 | /** 445 | * Returns the given lower_case_and_underscored_word as a CamelCased word. 446 | * 447 | * @param string $lowerCaseAndUnderscoredWord Word to camelize 448 | * @return string Camelized word. LikeThis. 449 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::camelize 450 | */ 451 | public static function camelize($lowerCaseAndUnderscoredWord) 452 | { 453 | if (!($result = self::_cache(__FUNCTION__, $lowerCaseAndUnderscoredWord))) { 454 | $result = str_replace(' ', '', Inflector::humanize($lowerCaseAndUnderscoredWord)); 455 | self::_cache(__FUNCTION__, $lowerCaseAndUnderscoredWord, $result); 456 | } 457 | return $result; 458 | } 459 | 460 | /** 461 | * Returns the given camelCasedWord as an underscored_word. 462 | * 463 | * @param string $camelCasedWord Camel-cased word to be "underscorized" 464 | * @return string Underscore-syntaxed version of the $camelCasedWord 465 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::underscore 466 | */ 467 | public static function underscore($camelCasedWord) 468 | { 469 | if (!($result = self::_cache(__FUNCTION__, $camelCasedWord))) { 470 | $result = strtolower(preg_replace('/(?<=\\w)([A-Z])/', '_\\1', $camelCasedWord)); 471 | self::_cache(__FUNCTION__, $camelCasedWord, $result); 472 | } 473 | return $result; 474 | } 475 | 476 | /** 477 | * Returns the given underscored_word_group as a Human Readable Word Group. 478 | * (Underscores are replaced by spaces and capitalized following words.) 479 | * 480 | * @param string $lowerCaseAndUnderscoredWord String to be made more readable 481 | * @return string Human-readable string 482 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::humanize 483 | */ 484 | public static function humanize($lowerCaseAndUnderscoredWord) 485 | { 486 | if (!($result = self::_cache(__FUNCTION__, $lowerCaseAndUnderscoredWord))) { 487 | $result = ucwords(str_replace('_', ' ', $lowerCaseAndUnderscoredWord)); 488 | self::_cache(__FUNCTION__, $lowerCaseAndUnderscoredWord, $result); 489 | } 490 | return $result; 491 | } 492 | 493 | /** 494 | * Returns corresponding table name for given model $className. ("people" for the model class "Person"). 495 | * 496 | * @param string $className Name of class to get database table name for 497 | * @return string Name of the database table for given class 498 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::tableize 499 | */ 500 | public static function tableize($className) 501 | { 502 | if (!($result = self::_cache(__FUNCTION__, $className))) { 503 | $result = Inflector::pluralize(Inflector::underscore($className)); 504 | self::_cache(__FUNCTION__, $className, $result); 505 | } 506 | return $result; 507 | } 508 | 509 | /** 510 | * Returns Cake model class name ("Person" for the database table "people".) for given database table. 511 | * 512 | * @param string $tableName Name of database table to get class name for 513 | * @return string Class name 514 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::classify 515 | */ 516 | public static function classify($tableName) 517 | { 518 | if (!($result = self::_cache(__FUNCTION__, $tableName))) { 519 | $result = Inflector::camelize(Inflector::singularize($tableName)); 520 | self::_cache(__FUNCTION__, $tableName, $result); 521 | } 522 | return $result; 523 | } 524 | 525 | /** 526 | * Returns camelBacked version of an underscored string. 527 | * 528 | * @param string $string 529 | * @return string in variable form 530 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::variable 531 | */ 532 | public static function variable($string) 533 | { 534 | if (!($result = self::_cache(__FUNCTION__, $string))) { 535 | $camelized = Inflector::camelize(Inflector::underscore($string)); 536 | $replace = strtolower(substr($camelized, 0, 1)); 537 | $result = preg_replace('/\\w/', $replace, $camelized, 1); 538 | self::_cache(__FUNCTION__, $string, $result); 539 | } 540 | return $result; 541 | } 542 | 543 | /** 544 | * Returns a string with all spaces converted to underscores (by default), accented 545 | * characters converted to non-accented characters, and non word characters removed. 546 | * 547 | * @param string $string the string you want to slug 548 | * @param string $replacement will replace keys in map 549 | * @return string 550 | * @link http://book.cakephp.org/2.0/en/core-utility-libraries/inflector.html#Inflector::slug 551 | */ 552 | public static function slug($string, $replacement = '_') 553 | { 554 | $quotedReplacement = preg_quote($replacement, '/'); 555 | 556 | $merge = array( 557 | '/[^\s\p{Ll}\p{Lm}\p{Lo}\p{Lt}\p{Lu}\p{Nd}]/mu' => ' ', 558 | '/\\s+/' => $replacement, 559 | sprintf('/^[%s]+|[%s]+$/', $quotedReplacement, $quotedReplacement) => '', 560 | ); 561 | 562 | $map = self::$_transliteration + $merge; 563 | return preg_replace(array_keys($map), array_values($map), $string); 564 | } 565 | } 566 | 567 | // Store the initial state 568 | Inflector::reset(); 569 | -------------------------------------------------------------------------------- /src/ThirdParty/Inflector/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | CakePHP(tm) : The Rapid Development PHP Framework (http://cakephp.org) 4 | Copyright (c) 2005-2013, Cake Software Foundation, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a 7 | copy of this software and associated documentation files (the "Software"), 8 | to deal in the Software without restriction, including without limitation 9 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 10 | and/or sell copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 22 | DEALINGS IN THE SOFTWARE. 23 | 24 | Cake Software Foundation, Inc. 25 | 1785 E. Sahara Avenue, 26 | Suite 490-204 27 | Las Vegas, Nevada 89104, 28 | United States of America. -------------------------------------------------------------------------------- /tests/.upgrade.yml: -------------------------------------------------------------------------------- 1 | mappings: 2 | DefaultPermissionManagerTest: colymba\RESTfulAPI\Tests\PermissionManagers\DefaultPermissionManagerTest 3 | DefaultQueryHandlerTest: colymba\RESTfulAPI\Tests\QueryHandlers\DefaultQueryHandlerTest 4 | BasicDeSerializerTest: colymba\RESTfulAPI\Tests\Serializers\DefaultDeSerializerTest 5 | BasicSerializerTest: colymba\RESTfulAPI\Tests\Serializers\DefaultSerializerTest 6 | RESTfulAPITest: colymba\RESTfulAPI\Tests\API\RESTfulAPITest 7 | ApiTestBook: colymba\RESTfulAPI\Tests\Fixtures\ApiTestBook 8 | ApiTestLibrary: colymba\RESTfulAPI\Tests\Fixtures\ApiTestLibrary 9 | ApiTestProduct: colymba\RESTfulAPI\Tests\Fixtures\ApiTestProduct 10 | ApiTestAuthor: colymba\RESTfulAPI\Tests\Fixtures\ApiTestAuthor 11 | RESTfulAPITester: colymba\RESTfulAPI\Tests\RESTfulAPITester 12 | TokenAuthenticatorTest: colymba\RESTfulAPI\Tests\Authenticators\TokenAuthenticatorTest 13 | -------------------------------------------------------------------------------- /tests/API/RESTfulAPITest.php: -------------------------------------------------------------------------------- 1 | update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); 45 | // ---------------- 46 | // Method Calls 47 | 48 | // Disabled by default 49 | $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class); 50 | $this->assertFalse($enabled, 'Access control should return FALSE by default'); 51 | 52 | // Enabled 53 | Config::inst()->update(ApiTestAuthor::class, 'api_access', true); 54 | $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class); 55 | $this->assertTrue($enabled, 'Access control should return TRUE when api_access is enbaled'); 56 | 57 | // Method specific 58 | Config::inst()->update(ApiTestAuthor::class, 'api_access', 'GET,POST'); 59 | 60 | $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class); 61 | $this->assertTrue($enabled, 'Access control should return TRUE when api_access is enbaled with default GET method'); 62 | 63 | $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class, 'POST'); 64 | $this->assertTrue($enabled, 'Access control should return TRUE when api_access match HTTP method'); 65 | 66 | $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class, 'PUT'); 67 | $this->assertFalse($enabled, 'Access control should return FALSE when api_access does not match method'); 68 | 69 | // ---------------- 70 | // API Calls 71 | /* 72 | // Access authorised 73 | $response = Director::test('api/ApiTestAuthor/1', null, null, 'GET'); 74 | $this->assertEquals( 75 | $response->getStatusCode(), 76 | 200 77 | ); 78 | 79 | // Access denied 80 | Config::inst()->update(ApiTestAuthor::class, 'api_access', false); 81 | $response = Director::test('api/ApiTestAuthor/1', null, null, 'GET'); 82 | $this->assertEquals( 83 | $response->getStatusCode(), 84 | 403 85 | ); 86 | 87 | // Access denied 88 | Config::inst()->update(ApiTestAuthor::class, 'api_access', 'POST'); 89 | $response = Director::test('api/ApiTestAuthor/1', null, null, 'GET'); 90 | $this->assertEquals( 91 | $response->getStatusCode(), 92 | 403 93 | ); 94 | */ 95 | } 96 | 97 | /* ********************************************************************** 98 | * CORS 99 | * */ 100 | 101 | /** 102 | * Check that CORS headers aren't set 103 | * when disabled via config 104 | * 105 | * @group CORSPreflight 106 | */ 107 | public function testCORSDisabled() 108 | { 109 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 110 | 'Enabled' => false, 111 | )); 112 | 113 | $requestHeaders = $this->getOPTIONSHeaders(); 114 | $response = Director::test('api/ApiTestBook/1', null, null, 'OPTIONS', null, $requestHeaders); 115 | $headers = $response->getHeaders(); 116 | 117 | $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers), 'CORS ORIGIN header should not be present'); 118 | $this->assertFalse(array_key_exists('Access-Control-Allow-Headers', $headers), 'CORS HEADER header should not be present'); 119 | $this->assertFalse(array_key_exists('Access-Control-Allow-Methods', $headers), 'CORS METHOD header should not be present'); 120 | $this->assertFalse(array_key_exists('Access-Control-Max-Age', $headers), 'CORS AGE header should not be present'); 121 | } 122 | 123 | /** 124 | * Checks default allow all CORS settings 125 | * 126 | * @group CORSPreflight 127 | */ 128 | public function testCORSAllowAll() 129 | { 130 | $corsConfig = Config::inst()->get(RESTfulAPI::class, 'cors'); 131 | $requestHeaders = $this->getOPTIONSHeaders('GET', 'http://google.com'); 132 | $response = Director::test('api/ApiTestBook/1', null, null, 'OPTIONS', null, $requestHeaders); 133 | $responseHeaders = $response->getHeaders(); 134 | 135 | $this->assertEquals( 136 | $requestHeaders['Origin'], 137 | $responseHeaders['Access-Control-Allow-Origin'], 138 | 'CORS headers should have same ORIGIN' 139 | ); 140 | 141 | $this->assertEquals( 142 | $corsConfig['Allow-Methods'], 143 | $responseHeaders['Access-Control-Allow-Methods'], 144 | 'CORS headers should have same METHOD' 145 | ); 146 | 147 | $this->assertEquals( 148 | $requestHeaders['Access-Control-Request-Headers'], 149 | $responseHeaders['Access-Control-Allow-Headers'], 150 | 'CORS headers should have same ALLOWED HEADERS' 151 | ); 152 | 153 | $this->assertEquals( 154 | $corsConfig['Max-Age'], 155 | $responseHeaders['Access-Control-Max-Age'], 156 | 'CORS headers should have same MAX AGE' 157 | ); 158 | } 159 | 160 | /** 161 | * Checks CORS only allow HTTP methods specify in config 162 | */ 163 | public function testCORSHTTPMethodFiltering() 164 | { 165 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 166 | 'Enabled' => true, 167 | 'Allow-Origin' => '*', 168 | 'Allow-Headers' => '*', 169 | 'Allow-Methods' => 'GET', 170 | 'Max-Age' => 86400, 171 | )); 172 | 173 | // Seding GET request, GET should be allowed 174 | $requestHeaders = $this->getRequestHeaders(); 175 | $response = Director::test('api/ApiTestBook/1', null, null, 'GET', null, $requestHeaders); 176 | $responseHeaders = $response->getHeaders(); 177 | 178 | $this->assertEquals( 179 | 'GET', 180 | $responseHeaders['access-control-allow-methods'], 181 | 'Only HTTP GET method should be allowed in access-control-allow-methods HEADER' 182 | ); 183 | 184 | // Seding POST request, only GET should be allowed 185 | $response = Director::test('api/ApiTestBook/1', null, null, 'POST', null, $requestHeaders); 186 | $responseHeaders = $response->getHeaders(); 187 | 188 | $this->assertEquals( 189 | 'GET', 190 | $responseHeaders['access-control-allow-methods'], 191 | 'Only HTTP GET method should be allowed in access-control-allow-methods HEADER' 192 | ); 193 | } 194 | 195 | /* ********************************************************************** 196 | * API REQUESTS 197 | * */ 198 | 199 | public function testFullBasicAPIRequest() 200 | { 201 | Config::inst()->update(RESTfulAPI::class, 'authentication_policy', false); 202 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); 203 | Config::inst()->update(ApiTestAuthor::class, 'api_access', true); 204 | 205 | // Default serializer 206 | Config::inst()->update(RESTfulAPI::class, 'dependencies', array( 207 | 'authenticator' => null, 208 | 'authority' => null, 209 | 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', 210 | 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer', 211 | )); 212 | Config::inst()->update(RESTfulAPI::class, 'dependencies', array( 213 | 'deSerializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer', 214 | )); 215 | 216 | $response = Director::test('api/apitestauthor/1', null, null, 'GET'); 217 | 218 | $this->assertEquals( 219 | 200, 220 | $response->getStatusCode(), 221 | "API request for existing record should resolve" 222 | ); 223 | 224 | $json = json_decode($response->getBody()); 225 | $this->assertEquals( 226 | JSON_ERROR_NONE, 227 | json_last_error(), 228 | "API request should return valid JSON" 229 | ); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /tests/Authenticators/TokenAuthenticatorTest.php: -------------------------------------------------------------------------------- 1 | array(TokenAuthExtension::class), 31 | ); 32 | 33 | protected function getAuthenticator() 34 | { 35 | $injector = new Injector(); 36 | $auth = new TokenAuthenticator(); 37 | 38 | $injector->inject($auth); 39 | 40 | return $auth; 41 | } 42 | 43 | public static function setUpBeforeClass() 44 | { 45 | parent::setUpBeforeClass(); 46 | 47 | Member::create(array( 48 | 'Email' => 'test@test.com', 49 | 'Password' => 'Test$password1', 50 | ))->write(); 51 | } 52 | 53 | /* ********************************************************** 54 | * TESTS 55 | * */ 56 | 57 | /** 58 | * Checks that the Member gets logged in 59 | * and a token is returned 60 | */ 61 | public function testLogin() 62 | { 63 | $member = Member::get()->filter(array( 64 | 'Email' => 'test@test.com', 65 | ))->first(); 66 | 67 | $auth = $this->getAuthenticator(); 68 | $request = new HTTPRequest( 69 | 'GET', 70 | 'api/auth/login', 71 | array( 72 | 'email' => 'test@test.com', 73 | 'pwd' => 'Test$password1', 74 | ) 75 | ); 76 | $request->setSession(new Session([])); 77 | 78 | $result = $auth->login($request); 79 | 80 | $this->assertEquals( 81 | Member::currentUserID(), 82 | $member->ID, 83 | "TokenAuth successful login should login the user" 84 | ); 85 | 86 | $this->assertTrue( 87 | is_string($result['token']), 88 | "TokenAuth successful login should return token as string" 89 | ); 90 | } 91 | 92 | /** 93 | * Checks that the Member is logged out 94 | */ 95 | public function testLogout() 96 | { 97 | $auth = $this->getAuthenticator(); 98 | $request = new HTTPRequest( 99 | 'GET', 100 | 'api/auth/logout', 101 | array( 102 | 'email' => 'test@test.com', 103 | ) 104 | ); 105 | $request->setSession(new Session([])); 106 | 107 | $result = $auth->logout($request); 108 | 109 | $this->assertNull( 110 | Member::currentUser(), 111 | "TokenAuth successful logout should logout the user" 112 | ); 113 | } 114 | 115 | /** 116 | * Checks that a string token is returned 117 | */ 118 | public function testGetToken() 119 | { 120 | $member = Member::get()->filter(array( 121 | 'Email' => 'test@test.com', 122 | ))->first(); 123 | 124 | $auth = $this->getAuthenticator(); 125 | $result = $auth->getToken($member->ID); 126 | 127 | $this->assertTrue( 128 | is_string($result), 129 | "TokenAuth getToken should return token as string" 130 | ); 131 | } 132 | 133 | /** 134 | * Checks that a new toekn is generated 135 | */ 136 | public function testResetToken() 137 | { 138 | $member = Member::get()->filter(array( 139 | 'Email' => 'test@test.com', 140 | ))->first(); 141 | 142 | $auth = $this->getAuthenticator(); 143 | $oldToken = $auth->getToken($member->ID); 144 | 145 | $auth->resetToken($member->ID); 146 | $newToken = $auth->getToken($member->ID); 147 | 148 | $this->assertThat( 149 | $oldToken, 150 | $this->logicalNot( 151 | $this->equalTo($newToken) 152 | ), 153 | "TokenAuth reset token should generate a new token" 154 | ); 155 | } 156 | 157 | /** 158 | * Checks authenticator return owner 159 | */ 160 | public function testGetOwner() 161 | { 162 | $member = Member::get()->filter(array( 163 | 'Email' => 'test@test.com', 164 | ))->first(); 165 | 166 | $auth = $this->getAuthenticator(); 167 | $auth->resetToken($member->ID); 168 | $token = $auth->getToken($member->ID); 169 | 170 | $request = new HTTPRequest( 171 | 'GET', 172 | 'api/ApiTestBook/1' 173 | ); 174 | $request->addHeader('X-Silverstripe-Apitoken', $token); 175 | $request->setSession(new Session([])); 176 | 177 | $result = $auth->getOwner($request); 178 | 179 | $this->assertEquals( 180 | 'test@test.com', 181 | $result->Email, 182 | "TokenAuth should return owner when passed valid token." 183 | ); 184 | } 185 | 186 | /** 187 | * Checks authentication works with a generated token 188 | */ 189 | public function testAuthenticate() 190 | { 191 | $member = Member::get()->filter(array( 192 | 'Email' => 'test@test.com', 193 | ))->first(); 194 | 195 | $auth = $this->getAuthenticator(); 196 | $request = new HTTPRequest( 197 | 'GET', 198 | 'api/ApiTestBook/1' 199 | ); 200 | $request->setSession(new Session([])); 201 | 202 | $auth->resetToken($member->ID); 203 | $token = $auth->getToken($member->ID); 204 | $request->addHeader('X-Silverstripe-Apitoken', $token); 205 | 206 | $result = $auth->authenticate($request); 207 | 208 | $this->assertTrue( 209 | $result, 210 | "TokenAuth authentication success should return true" 211 | ); 212 | 213 | $auth->resetToken($member->ID); 214 | $result = $auth->authenticate($request); 215 | 216 | $this->assertContainsOnlyInstancesOf( 217 | RESTfulAPIError::class, 218 | array($result), 219 | "TokenAuth authentication failure should return a RESTfulAPIError" 220 | ); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /tests/Fixtures/ApiTestAuthor.php: -------------------------------------------------------------------------------- 1 | 'Varchar(255)', 29 | 'IsMan' => 'Boolean', 30 | ); 31 | 32 | private static $has_many = array( 33 | 'Books' => ApiTestBook::class, 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /tests/Fixtures/ApiTestBook.php: -------------------------------------------------------------------------------- 1 | 'Varchar(255)', 31 | 'Pages' => 'Int', 32 | ); 33 | 34 | private static $has_one = array( 35 | 'Author' => ApiTestAuthor::class, 36 | ); 37 | 38 | private static $belongs_many_many = array( 39 | 'Libraries' => ApiTestLibrary::class, 40 | ); 41 | 42 | public function validate() 43 | { 44 | if ($this->Pages > 100) { 45 | $result = ValidationResult::create()->addError('Too many pages'); 46 | } else { 47 | $result = ValidationResult::create(); 48 | } 49 | 50 | return $result; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Fixtures/ApiTestLibrary.php: -------------------------------------------------------------------------------- 1 | 'Varchar(255)', 29 | ); 30 | 31 | private static $many_many = array( 32 | 'Books' => ApiTestBook::class, 33 | ); 34 | 35 | public function canView($member = null) 36 | { 37 | return Permission::check('RESTfulAPI_VIEW', 'any', $member); 38 | } 39 | 40 | public function canEdit($member = null) 41 | { 42 | return Permission::check('RESTfulAPI_EDIT', 'any', $member); 43 | } 44 | 45 | public function canCreate($member = null, $context = []) 46 | { 47 | return Permission::check('RESTfulAPI_CREATE', 'any', $member); 48 | } 49 | 50 | public function canDelete($member = null) 51 | { 52 | return Permission::check('RESTfulAPI_DELETE', 'any', $member); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Fixtures/ApiTestProduct.php: -------------------------------------------------------------------------------- 1 | 'Varchar(64)', 28 | 'Soldout' => 'Boolean', 29 | ); 30 | 31 | private static $api_access = true; 32 | 33 | public function onAfterDeserialize(&$payload) 34 | { 35 | // don't allow setting `Soldout` via REST API 36 | unset($payload['Soldout']); 37 | } 38 | 39 | public function onBeforeDeserialize(&$rawJson) 40 | { 41 | self::$rawJSON = $rawJson; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Fixtures/ApiTestWidget.php: -------------------------------------------------------------------------------- 1 | 'Varchar(255)', 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /tests/PermissionManagers/DefaultPermissionManagerTest.php: -------------------------------------------------------------------------------- 1 | array(TokenAuthExtension::class), 31 | ); 32 | 33 | protected static $extra_dataobjects = array( 34 | ApiTestLibrary::class, 35 | ); 36 | 37 | public static function setUpBeforeClass() 38 | { 39 | parent::setUpBeforeClass(); 40 | 41 | Member::create(array( 42 | 'Email' => 'admin@api.com', 43 | 'Password' => 'Admin$password1', 44 | ))->write(); 45 | 46 | $member = Member::get()->filter(array( 47 | 'Email' => 'admin@api.com', 48 | ))->first(); 49 | 50 | $member->addToGroupByCode('restfulapi-administrators'); 51 | 52 | Member::create(array( 53 | 'Email' => 'stranger@api.com', 54 | 'Password' => 'Stranger$password1', 55 | ))->write(); 56 | } 57 | 58 | protected function getAdminToken() 59 | { 60 | $response = Director::test('api/auth/login?email=admin@api.com&pwd=Admin$password1'); 61 | $json = json_decode($response->getBody()); 62 | return $json->token; 63 | } 64 | 65 | protected function getStrangerToken() 66 | { 67 | $response = Director::test('api/auth/login?email=stranger@api.com&pwd=Stranger$password1'); 68 | $json = json_decode($response->getBody()); 69 | return $json->token; 70 | } 71 | 72 | /* ********************************************************** 73 | * TESTS 74 | * */ 75 | 76 | /** 77 | * Test READ permissions are honoured 78 | */ 79 | public function testReadPermissions() 80 | { 81 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); 82 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 83 | 'Enabled' => false, 84 | )); 85 | 86 | // GET with permission = OK 87 | $requestHeaders = $this->getRequestHeaders(); 88 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); 89 | $response = Director::test('api/apitestlibrary/1', null, null, 'GET', null, $requestHeaders); 90 | 91 | $this->assertEquals( 92 | $response->getStatusCode(), 93 | 200, 94 | "Member of 'restfulapi-administrators' Group should be able to READ records." 95 | ); 96 | 97 | // GET with NO Permission = BAD 98 | $requestHeaders = $this->getRequestHeaders(); 99 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); 100 | $response = Director::test('api/apitestlibrary/1', null, null, 'GET', null, $requestHeaders); 101 | 102 | $this->assertEquals( 103 | $response->getStatusCode(), 104 | 403, 105 | "Member without permission should NOT be able to READ records." 106 | ); 107 | } 108 | 109 | /** 110 | * Test EDIT permissions are honoured 111 | */ 112 | public function testEditPermissions() 113 | { 114 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); 115 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 116 | 'Enabled' => false, 117 | )); 118 | 119 | // PUT with permission = OK 120 | $requestHeaders = $this->getRequestHeaders(); 121 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); 122 | $response = Director::test('api/apitestlibrary/1', null, null, 'PUT', '{"Name":"Api"}', $requestHeaders); 123 | 124 | $this->assertEquals( 125 | $response->getStatusCode(), 126 | 200, 127 | "Member of 'restfulapi-administrators' Group should be able to EDIT records." 128 | ); 129 | 130 | // PUT with NO Permission = BAD 131 | $requestHeaders = $this->getRequestHeaders(); 132 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); 133 | $response = Director::test('api/apitestlibrary/1', null, null, 'PUT', '{"Name":"Api"}', $requestHeaders); 134 | 135 | $this->assertEquals( 136 | $response->getStatusCode(), 137 | 403, 138 | "Member without permission should NOT be able to EDIT records." 139 | ); 140 | } 141 | 142 | /** 143 | * Test CREATE permissions are honoured 144 | */ 145 | public function testCreatePermissions() 146 | { 147 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); 148 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 149 | 'Enabled' => false, 150 | )); 151 | 152 | // POST with permission = OK 153 | $requestHeaders = $this->getRequestHeaders(); 154 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); 155 | $response = Director::test('api/apitestlibrary', null, null, 'POST', '{"Name":"Api"}', $requestHeaders); 156 | 157 | $this->assertEquals( 158 | $response->getStatusCode(), 159 | 200, 160 | "Member of 'restfulapi-administrators' Group should be able to CREATE records." 161 | ); 162 | 163 | // POST with NO Permission = BAD 164 | $requestHeaders = $this->getRequestHeaders(); 165 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); 166 | $response = Director::test('api/apitestlibrary', null, null, 'POST', '{"Name":"Api"}', $requestHeaders); 167 | 168 | $this->assertEquals( 169 | $response->getStatusCode(), 170 | 403, 171 | "Member without permission should NOT be able to CREATE records." 172 | ); 173 | } 174 | 175 | /** 176 | * Test DELETE permissions are honoured 177 | */ 178 | public function testDeletePermissions() 179 | { 180 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); 181 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 182 | 'Enabled' => false, 183 | )); 184 | 185 | // DELETE with permission = OK 186 | $requestHeaders = $this->getRequestHeaders(); 187 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); 188 | $response = Director::test('api/apitestlibrary/1', null, null, 'DELETE', null, $requestHeaders); 189 | 190 | $this->assertEquals( 191 | $response->getStatusCode(), 192 | 200, 193 | "Member of 'restfulapi-administrators' Group should be able to DELETE records." 194 | ); 195 | 196 | // DELETE with NO Permission = BAD 197 | $requestHeaders = $this->getRequestHeaders(); 198 | $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); 199 | $response = Director::test('api/apitestlibrary/1', null, null, 'DELETE', null, $requestHeaders); 200 | 201 | $this->assertEquals( 202 | $response->getStatusCode(), 203 | 403, 204 | "Member without permission should NOT be able to DELETE records." 205 | ); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /tests/QueryHandlers/DefaultQueryHandlerTest.php: -------------------------------------------------------------------------------- 1 | update(ApiTestBook::class, 'api_access', true); 51 | Config::inst()->update(ApiTestWidget::class, 'api_access', true); 52 | 53 | $widget = ApiTestWidget::create(['Name' => 'TestWidget1']); 54 | $widget->write(); 55 | $widget = ApiTestWidget::create(['Name' => 'TestWidget2']); 56 | $widget->write(); 57 | } 58 | 59 | protected function getHTTPRequest($method = 'GET', $class = ApiTestBook::class, $id = '', $params = array()) 60 | { 61 | $request = new HTTPRequest( 62 | $method, 63 | 'api/' . $class . '/' . $id, 64 | $params 65 | ); 66 | $request->match($this->url_pattern); 67 | $request->setRouteParams(array( 68 | 'Controller' => 'RESTfulAPI', 69 | )); 70 | 71 | return $request; 72 | } 73 | 74 | protected function getQueryHandler() 75 | { 76 | $injector = new Injector(); 77 | $qh = new DefaultQueryHandler(); 78 | 79 | $injector->inject($qh); 80 | 81 | return $qh; 82 | } 83 | 84 | public static function setUpBeforeClass() 85 | { 86 | parent::setUpBeforeClass(); 87 | 88 | $product = ApiTestProduct::create(array( 89 | 'Title' => 'Sold out product', 90 | 'Soldout' => true, 91 | )); 92 | $product->write(); 93 | } 94 | 95 | /* ********************************************************** 96 | * TESTS 97 | * */ 98 | 99 | /** 100 | * Checks that query parameters are parsed properly 101 | */ 102 | public function testQueryParametersParsing() 103 | { 104 | $qh = $this->getQueryHandler(); 105 | $request = $this->getHTTPRequest('GET', ApiTestBook::class, '1', array('Title__StartsWith' => 'K')); 106 | $params = $qh->parseQueryParameters($request->getVars()); 107 | $params = array_shift($params); 108 | 109 | $this->assertEquals( 110 | $params['Column'], 111 | 'Title', 112 | 'Column parameter name mismatch' 113 | ); 114 | $this->assertEquals( 115 | $params['Value'], 116 | 'K', 117 | 'Value parameter mismatch' 118 | ); 119 | $this->assertEquals( 120 | $params['Modifier'], 121 | 'StartsWith', 122 | 'Modifier parameter mismatch' 123 | ); 124 | } 125 | 126 | /** 127 | * Checks that access to DataObject with api_access config disabled return error 128 | */ 129 | public function testAPIDisabled() 130 | { 131 | Config::inst()->update(ApiTestBook::class, 'api_access', false); 132 | 133 | $qh = $this->getQueryHandler(); 134 | $request = $this->getHTTPRequest('GET', ApiTestBook::class, '1'); 135 | $result = $qh->handleQuery($request); 136 | 137 | $this->assertContainsOnlyInstancesOf( 138 | RESTfulAPIError::class, 139 | array($result), 140 | 'Request for DataObject with api_access set to false should return a RESTfulAPIError' 141 | ); 142 | } 143 | 144 | /** 145 | * Checks single record requests 146 | */ 147 | public function testFindSingleModel() 148 | { 149 | $qh = $this->getQueryHandler(); 150 | $request = $this->getHTTPRequest('GET', ApiTestBook::class, '1'); 151 | $result = $qh->handleQuery($request); 152 | 153 | $this->assertContainsOnlyInstancesOf( 154 | ApiTestBook::class, 155 | array($result), 156 | 'Single model request should return a DataObject of class model' 157 | ); 158 | $this->assertEquals( 159 | 1, 160 | $result->ID, 161 | 'IDs mismatch. DataObject is not the record requested' 162 | ); 163 | } 164 | 165 | /** 166 | * Checks multiple records requests 167 | */ 168 | public function testFindMultipleModels() 169 | { 170 | $qh = $this->getQueryHandler(); 171 | $request = $this->getHTTPRequest('GET', ApiTestBook::class); 172 | $result = $qh->handleQuery($request); 173 | 174 | $this->assertContainsOnlyInstancesOf( 175 | DataList::class, 176 | array($result), 177 | 'Request for multiple models should return a DataList' 178 | ); 179 | 180 | $this->assertGreaterThan( 181 | 1, 182 | $result->toArray(), 183 | 'Request should return more than 1 result' 184 | ); 185 | } 186 | 187 | /** 188 | * Checks fallback for models without explicit mapping 189 | */ 190 | public function testModelMappingFallback() 191 | { 192 | $qh = $this->getQueryHandler(); 193 | $request = $this->getHTTPRequest('GET', ApiTestWidget::class, '1'); 194 | $result = $qh->handleQuery($request); 195 | 196 | $this->assertContainsOnlyInstancesOf( 197 | ApiTestWidget::class, 198 | array($result), 199 | 'Unmapped model should fall back to standard mapping' 200 | ); 201 | } 202 | 203 | /** 204 | * Checks max record limit config 205 | */ 206 | public function testMaxRecordsLimit() 207 | { 208 | Config::inst()->update(DefaultQueryHandler::class, 'max_records_limit', 1); 209 | 210 | $qh = $this->getQueryHandler(); 211 | $request = $this->getHTTPRequest('GET', ApiTestBook::class); 212 | $result = $qh->handleQuery($request); 213 | 214 | $this->assertCount( 215 | 1, 216 | $result->toArray(), 217 | 'Request for multiple models should implement limit set by max_records_limit config' 218 | ); 219 | } 220 | 221 | /** 222 | * Checks new record creation 223 | */ 224 | public function testCreateModel() 225 | { 226 | $existingRecords = ApiTestBook::get()->toArray(); 227 | 228 | $qh = $this->getQueryHandler(); 229 | $request = $this->getHTTPRequest('POST', ApiTestBook::class); 230 | 231 | $body = json_encode(array('Title' => 'New Test Book')); 232 | $request->setBody($body); 233 | 234 | $result = $qh->createModel(ApiTestBook::class, $request); 235 | $rewRecords = ApiTestBook::get()->toArray(); 236 | 237 | $this->assertContainsOnlyInstancesOf( 238 | DataObject::class, 239 | array($result), 240 | 'Create model should return a DataObject' 241 | ); 242 | 243 | $this->assertEquals( 244 | count($existingRecords) + 1, 245 | count($rewRecords), 246 | 'Create model should create a database entry' 247 | ); 248 | 249 | $this->assertEquals( 250 | 'New Test Book', 251 | $result->Title, 252 | "Created model title doesn't match" 253 | ); 254 | 255 | // failing tests return error? 256 | } 257 | 258 | /** 259 | * Checks new record creation 260 | */ 261 | public function testModelValidation() 262 | { 263 | $qh = $this->getQueryHandler(); 264 | $request = $this->getHTTPRequest('POST', ApiTestBook::class); 265 | 266 | $body = json_encode(array('Title' => 'New Test Book', 'Pages' => 101)); 267 | $request->setBody($body); 268 | 269 | $result = $qh->createModel(ApiTestBook::class, $request); 270 | 271 | $this->assertEquals( 272 | 'Too many pages', 273 | $result->message, 274 | "Model with validation error should return the validation error" 275 | ); 276 | } 277 | 278 | /** 279 | * Checks record update 280 | */ 281 | public function testUpdateModel() 282 | { 283 | $firstRecord = ApiTestBook::get()->first(); 284 | 285 | $qh = $this->getQueryHandler(); 286 | $request = $this->getHTTPRequest('PUT', ApiTestBook::class); 287 | 288 | $newTitle = $firstRecord->Title . ' UPDATED'; 289 | $body = json_encode(array('Title' => $newTitle)); 290 | $request->setBody($body); 291 | 292 | $result = $qh->updateModel(ApiTestBook::class, $firstRecord->ID, $request); 293 | $updatedRecord = DataObject::get_by_id(ApiTestBook::class, $firstRecord->ID); 294 | 295 | $this->assertContainsOnlyInstancesOf( 296 | DataObject::class, 297 | array($result), 298 | 'Update model should return a DataObject' 299 | ); 300 | 301 | $this->assertEquals( 302 | $newTitle, 303 | $updatedRecord->Title, 304 | "Update model didn't update database record" 305 | ); 306 | 307 | // failing tests return error? 308 | } 309 | 310 | /** 311 | * Checks record deletion 312 | */ 313 | public function testDeleteModel() 314 | { 315 | $firstRecord = ApiTestBook::get()->first(); 316 | 317 | $qh = $this->getQueryHandler(); 318 | $request = $this->getHTTPRequest('DELETE', ApiTestBook::class); 319 | $result = $qh->deleteModel(ApiTestBook::class, $firstRecord->ID, $request); 320 | 321 | $deletedRecord = DataObject::get_by_id(ApiTestBook::class, $firstRecord->ID); 322 | 323 | $this->assertNull( 324 | $deletedRecord, 325 | 'Delete model should delete a database record' 326 | ); 327 | } 328 | 329 | public function testAfterDeserialize() 330 | { 331 | $product = ApiTestProduct::get()->first(); 332 | $qh = $this->getQueryHandler(); 333 | $request = $this->getHTTPRequest('PUT', ApiTestProduct::class, $product->ID); 334 | $body = json_encode(array( 335 | 'Title' => 'Making product available', 336 | 'Soldout' => false, 337 | )); 338 | $request->setBody($body); 339 | 340 | $updatedProduct = $qh->handleQuery($request); 341 | 342 | $this->assertContainsOnlyInstancesOf( 343 | DataObject::class, 344 | array($updatedProduct), 345 | 'Update model should return a DataObject' 346 | ); 347 | 348 | $this->assertEquals( 349 | ApiTestProduct::$rawJSON, 350 | $body, 351 | "Raw JSON passed into 'onBeforeDeserialize' should match request payload" 352 | ); 353 | 354 | $this->assertTrue( 355 | $updatedProduct->Soldout == 1, 356 | "Product should still be sold out, because 'onAfterDeserialize' unset the data bafore writing" 357 | ); 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /tests/RESTfulAPITester.php: -------------------------------------------------------------------------------- 1 | 'Peter', 34 | 'IsMan' => true, 35 | )); 36 | $marie = ApiTestAuthor::create(array( 37 | 'Name' => 'Marie', 38 | 'IsMan' => false, 39 | )); 40 | 41 | $bible = ApiTestBook::create(array( 42 | 'Title' => 'The Bible', 43 | 'Pages' => 60, 44 | )); 45 | $kamasutra = ApiTestBook::create(array( 46 | 'Title' => 'Kama Sutra', 47 | 'Pages' => 70, 48 | )); 49 | 50 | $helsinki = ApiTestLibrary::create(array( 51 | 'Name' => 'Helsinki', 52 | )); 53 | $paris = ApiTestLibrary::create(array( 54 | 'Name' => 'Paris', 55 | )); 56 | 57 | // write to DB 58 | $peter->write(); 59 | $marie->write(); 60 | $bible->write(); 61 | $kamasutra->write(); 62 | $helsinki->write(); 63 | $paris->write(); 64 | 65 | // relations 66 | $peter->Books()->add($bible); 67 | $marie->Books()->add($kamasutra); 68 | 69 | $helsinki->Books()->add($bible); 70 | $helsinki->Books()->add($kamasutra); 71 | $paris->Books()->add($kamasutra); 72 | 73 | // since it doesn't seem to be called automatically 74 | $ext = new GroupExtension(); 75 | $ext->requireDefaultRecords(); 76 | } 77 | 78 | public function setDefaultApiConfig() 79 | { 80 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); 81 | 82 | Config::inst()->update(RESTfulAPI::class, 'dependencies', array( 83 | 'authenticator' => '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator', 84 | 'authority' => '%$Colymba\RESTfulAPI\PermissionManagers\DefaultPermissionManager', 85 | 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', 86 | 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer', 87 | )); 88 | 89 | Config::inst()->update(RESTfulAPI::class, 'cors', array( 90 | 'Enabled' => true, 91 | 'Allow-Origin' => '*', 92 | 'Allow-Headers' => '*', 93 | 'Allow-Methods' => 'OPTIONS, POST, GET, PUT, DELETE', 94 | 'Max-Age' => 86400, 95 | )); 96 | 97 | Config::inst()->update(DefaultQueryHandler::class, 'dependencies', array( 98 | 'deSerializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer' 99 | )); 100 | 101 | Config::inst()->update(DefaultQueryHandler::class, 'models', array( 102 | 'apitestauthor' => 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestAuthor', 103 | 'apitestlibrary' => 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestLibrary', 104 | ) 105 | ); 106 | } 107 | 108 | public function getOPTIONSHeaders($method = 'GET', $site = null) 109 | { 110 | if (!$site) { 111 | $site = Director::absoluteBaseURL(); 112 | } 113 | $host = parse_url($site, PHP_URL_HOST); 114 | 115 | return array( 116 | 'Accept' => '*/*', 117 | 'Accept-Encoding' => 'gzip,deflate,sdch', 118 | 'Accept-Language' => 'en-GB,fr;q=0.8,en-US;q=0.6,en;q=0.4', 119 | 'Access-Control-Request-Headers' => 'accept, x-silverstripe-apitoken', 120 | 'Access-Control-Request-Method' => $method, 121 | 'Cache-Control' => 'no-cache', 122 | 'Connection' => 'keep-alive', 123 | 'Host' => $host, 124 | 'Origin' => 'http://' . $host, 125 | 'Pragma' => 'no-cache', 126 | 'Referer' => 'http://' . $host . '/', 127 | 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', 128 | ); 129 | } 130 | 131 | public function getRequestHeaders($site = null) 132 | { 133 | if (!$site) { 134 | $site = Director::absoluteBaseURL(); 135 | } 136 | $host = parse_url($site, PHP_URL_HOST); 137 | 138 | return array( 139 | 'Accept' => 'application/json, text/javascript, */*; q=0.01', 140 | 'Accept-Encoding' => 'gzip,deflate,sdch', 141 | 'Accept-Language' => 'en-GB,fr;q=0.8,en-US;q=0.6,en;q=0.4', 142 | 'Cache-Control' => 'no-cache', 143 | 'Connection' => 'keep-alive', 144 | 'Host' => $host, 145 | 'Origin' => 'http://' . $host, 146 | 'Pragma' => 'no-cache', 147 | 'Referer' => 'http://' . $host . '/', 148 | 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', 149 | 'X-Silverstripe-Apitoken' => 'secret key', 150 | ); 151 | } 152 | 153 | public static function setUpBeforeClass() 154 | { 155 | parent::setUpBeforeClass(); 156 | 157 | if (self::getExtraDataobjects()) { 158 | self::generateDBEntries(); 159 | } 160 | } 161 | 162 | public function setUp() 163 | { 164 | parent::setUp(); 165 | 166 | $this->setDefaultApiConfig(); 167 | 168 | Config::inst()->update(Director::class, 'alternate_base_url', 'http://mysite.com/'); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Serializers/DefaultDeSerializerTest.php: -------------------------------------------------------------------------------- 1 | inject($deserializer); 39 | 40 | return $deserializer; 41 | } 42 | 43 | /* ********************************************************** 44 | * TESTS 45 | * */ 46 | 47 | /** 48 | * Checks payload deserialization 49 | */ 50 | public function testDeserialize() 51 | { 52 | $deserializer = $this->getDeSerializer(); 53 | $json = json_encode(array('Name' => 'Some name')); 54 | $result = $deserializer->deserialize($json); 55 | 56 | $this->assertTrue( 57 | is_array($result), 58 | "Default DeSerialize should return an array" 59 | ); 60 | 61 | $this->assertEquals( 62 | "Some name", 63 | $result['Name'], 64 | "Default DeSerialize should not change values" 65 | ); 66 | } 67 | 68 | /** 69 | * Checks payload column/class names unformatting 70 | */ 71 | public function testUnformatName() 72 | { 73 | $deserializer = $this->getDeSerializer(); 74 | 75 | $column = 'Name'; 76 | $class = 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestAuthor'; 77 | 78 | $this->assertEquals( 79 | $column, 80 | $deserializer->unformatName($column), 81 | "Default DeSerialize should not change name formatting" 82 | ); 83 | 84 | $this->assertEquals( 85 | ApiTestAuthor::class, 86 | $deserializer->unformatName($class), 87 | "Default DeSerialize should return ucfirst class name" 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Serializers/DefaultSerializerTest.php: -------------------------------------------------------------------------------- 1 | inject($serializer); 42 | 43 | return $serializer; 44 | } 45 | 46 | /*********************************************************** 47 | * TESTS 48 | **/ 49 | 50 | /** 51 | * Checks serializer content type access 52 | */ 53 | public function testContentType() 54 | { 55 | $serializer = $this->getSerializer(); 56 | $contentType = $serializer->getcontentType(); 57 | 58 | $this->assertTrue( 59 | is_string($contentType), 60 | 'Default Serializer getcontentType() should return string' 61 | ); 62 | } 63 | 64 | /** 65 | * Checks data serialization 66 | */ 67 | public function testSerialize() 68 | { 69 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', false); 70 | $serializer = $this->getSerializer(); 71 | 72 | // test single dataObject serialization 73 | $dataObject = ApiTestAuthor::get()->filter(array('Name' => 'Peter'))->first(); 74 | $jsonString = $serializer->serialize($dataObject); 75 | $jsonObject = json_decode($jsonString); 76 | 77 | $this->assertEquals( 78 | JSON_ERROR_NONE, 79 | json_last_error(), 80 | 'Default Serialize dataObject should return valid JSON' 81 | ); 82 | 83 | $this->assertEquals( 84 | $dataObject->Name, 85 | $jsonObject->Name, 86 | 'Default Serialize should return an object and not modify values' 87 | ); 88 | 89 | // test datalist serialization 90 | $dataList = ApiTestAuthor::get(); 91 | $jsonString = $serializer->serialize($dataList); 92 | $jsonArray = json_decode($jsonString); 93 | 94 | $this->assertEquals( 95 | JSON_ERROR_NONE, 96 | json_last_error(), 97 | 'Default Serialize dataList should return valid JSON' 98 | ); 99 | 100 | $this->assertTrue( 101 | is_array($jsonArray), 102 | 'Default Serialize dataObject should return an object' 103 | ); 104 | } 105 | 106 | /** 107 | * Checks embedded records config 108 | */ 109 | public function testEmbeddedRecords() 110 | { 111 | Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); 112 | Config::inst()->update(ApiTestLibrary::class, 'api_access', true); 113 | Config::inst()->update(RESTfulAPI::class, 'embedded_records', array( 114 | 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestLibrary' => array('Books'), 115 | )); 116 | 117 | $serializer = $this->getSerializer(); 118 | $dataObject = ApiTestLibrary::get()->filter(array('Name' => 'Helsinki'))->first(); 119 | 120 | // api access disabled 121 | Config::inst()->update(ApiTestBook::class, 'api_access', false); 122 | $result = $serializer->serialize($dataObject); 123 | $result = json_decode($result); 124 | 125 | $this->assertEmpty( 126 | $result->Books, 127 | 'Default Serialize should return empty array for DataObject without permission' 128 | ); 129 | 130 | // api access enabled 131 | Config::inst()->update(ApiTestBook::class, 'api_access', true); 132 | $result = $serializer->serialize($dataObject); 133 | $result = json_decode($result); 134 | 135 | $this->assertTrue( 136 | is_numeric($result->Books[0]->ID), 137 | 'Default Serialize should return a full record for embedded records' 138 | ); 139 | } 140 | 141 | /** 142 | * Checks column name formatting 143 | */ 144 | public function testFormatName() 145 | { 146 | $serializer = $this->getSerializer(); 147 | 148 | $column = 'Name'; 149 | 150 | $this->assertEquals( 151 | $column, 152 | $serializer->formatName($column), 153 | 'Default Serialize should not change name formatting' 154 | ); 155 | } 156 | 157 | /** 158 | * Ensures the api_fields config value will define the fields that should be returned, including related models 159 | */ 160 | public function testReturnDefinedApiFieldsOnly() 161 | { 162 | Config::inst()->update(ApiTestAuthor::class, 'api_access', true); 163 | 164 | $serializer = $this->getSerializer(); 165 | 166 | $dataObject = ApiTestAuthor::get()->filter(array('Name' => 'Marie'))->first(); 167 | 168 | Config::inst()->update(ApiTestAuthor::class, 'api_fields', array('Name')); 169 | 170 | $result = $serializer->serialize($dataObject); 171 | $result = json_decode($result); 172 | 173 | $this->assertFalse( 174 | property_exists($result, 'IsMan'), 175 | 'You should be able to exclude DB fields using api_fields config.' 176 | ); 177 | 178 | $this->assertFalse( 179 | property_exists($result, 'Books'), 180 | 'You should be able to exclude related models by not including them in api_fields.' 181 | ); 182 | 183 | Config::inst()->update(ApiTestAuthor::class, 'api_fields', array('IsMan', 'Books')); 184 | 185 | $result = $serializer->serialize($dataObject); 186 | $result = json_decode($result); 187 | 188 | $this->assertTrue( 189 | property_exists($result, 'IsMan'), 190 | 'Fields listed in a DataObjects api_fields config array should be visible in the serialized API output.' 191 | ); 192 | 193 | $this->assertTrue( 194 | property_exists($result, 'Books'), 195 | 'Related model name included in api_fields should be returned in output.' 196 | ); 197 | } 198 | } 199 | --------------------------------------------------------------------------------