Compare commits

...

121 Commits
1.0.4 ... 2

Author SHA1 Message Date
Guy Sartorelli 5170146d07
Merge branch '2.6' into 2 2023-04-26 12:47:23 +12:00
Guy Sartorelli e5c349b1b1
MNT Revert erroneous dependency changes (#107) 2023-03-28 17:11:38 +13:00
Maxime Rainville 2a122a32ff
Merge pull request #106 from creative-commoners/pulls/2/dispatch-ci
MNT Use gha-dispatch-ci
2023-03-23 14:19:16 +13:00
Steve Boyd 5a51aebc89 MNT Use gha-dispatch-ci 2023-03-21 13:40:46 +13:00
Guy Sartorelli 729494737d
MNT Update development dependencies 2023-03-10 16:35:09 +13:00
Guy Sartorelli 45883df8c0
MNT Update release dependencies 2023-03-10 16:35:06 +13:00
Guy Sartorelli 1376bb4d41
MNT Update development dependencies 2023-03-10 12:21:30 +13:00
Sabina Talipova 61fe0c71c3
Merge pull request #101 from creative-commoners/pulls/2/stop-using-depr
API Stop using deprecated API
2022-12-05 16:36:13 +13:00
Steve Boyd bdafc33228 API Stop using deprecated API 2022-11-28 19:19:41 +13:00
Steve Boyd 3dc0eb0828 Merge branch '2.5' into 2 2022-08-02 18:56:32 +12:00
Steve Boyd 32a5fd1db9 Merge branch '2.4' into 2.5 2022-08-02 18:56:28 +12:00
Guy Sartorelli 6f79feac51
Merge pull request #100 from creative-commoners/pulls/2.4/standardise-modules
MNT Standardise modules
2022-08-02 15:34:05 +12:00
Steve Boyd 31256f148a MNT Standardise modules 2022-08-01 16:22:45 +12:00
Steve Boyd abf06ba300 Merge branch '2.5' into 2 2022-07-25 11:32:47 +12:00
Steve Boyd 06b5522aca Merge branch '2.4' into 2.5 2022-07-25 11:32:43 +12:00
Guy Sartorelli c2498f9cf6
Merge pull request #99 from creative-commoners/pulls/2.4/module-standards
MNT Use GitHub Actions CI
2022-07-15 17:17:11 +12:00
Steve Boyd bc6e0ff8c4 MNT Use GitHub Actions CI 2022-07-05 19:05:25 +12:00
Guy Sartorelli fa77f23f66
Merge pull request #98 from creative-commoners/pulls/2/php81
ENH PHP 8.1 compatibility
2022-04-26 17:57:49 +12:00
Steve Boyd 5bff584246 ENH PHP 8.1 compatibility 2022-04-13 13:42:48 +12:00
Maxime Rainville 94bad49802
Merge pull request #97 from creative-commoners/pulls/2/php74
DEP Set PHP 7.4 as the minimum version
2022-02-18 22:05:59 +13:00
Steve Boyd e0bab7d623 DEP Set PHP 7.4 as the minimum version 2022-02-10 17:32:12 +13:00
Maxime Rainville e84a66dad3
Merge pull request #96 from creative-commoners/pulls/2/sapphire-test-nine
API phpunit 9 support
2021-11-01 22:07:47 +13:00
Steve Boyd 4a2716cd7d API phpunit 9 support 2021-10-27 18:12:45 +13:00
Steve Boyd c11b7cdcda Merge branch '2.3' into 2 2021-05-21 13:58:02 +12:00
Maxime Rainville 88c509b0d1 MNT Remove obsolete branch-alias 2021-05-05 11:17:49 +12:00
Steve Boyd 3da21bb6c2
Update build status badge 2021-01-21 16:40:37 +13:00
Steve Boyd 0cac389a9f Merge branch '2.2' into 2 2021-01-02 20:28:18 +13:00
Maxime Rainville 96654b86b2
Merge pull request #92 from creative-commoners/pulls/2.2/travis-shared
MNT Travis shared config
2020-12-21 15:42:21 +13:00
Steve Boyd 77be6bc44b MNT Travis shared config, use sminnee/phpunit 2020-12-01 16:33:21 +13:00
Maxime Rainville 6e62e846ce Merge branch '2.2' into 2 2020-10-22 13:58:33 +13:00
Robbie Averill 1e4fe6fbbe
Merge pull request #90 from creative-commoners/pulls/2.2/travis
Update travis 2.2
2020-06-23 09:44:08 -07:00
Steve Boyd 1a2527d210 Update travis 2020-06-23 15:43:09 +12:00
Garion Herman 444d1aa14f
Merge pull request #83 from creative-commoners/pulls/master/restore-php-5.6-support
BUG Restoring PHP5.6 support
2019-10-18 18:36:47 +13:00
Maxime Rainville ac0b263683 BUG Restoring PHP5.6 support 2019-10-18 16:38:21 +13:00
Robbie Averill b3acde80f6 Merge branch '2.2' 2019-08-15 10:13:35 +12:00
Robbie Averill 2fa54bc101 Remove obsolete branch alias 2019-08-15 10:12:42 +12:00
Sander Hagenaars 0b734c21c6 NEW Aliases can now be defined for DataObject endpoints (#80)
* set endpoint_aliases on RestfulServer to specify fixed aliases for exposed dataobjects

* removed wrong use statements from older project

* better docblock

* use correct obj ClassName in RestfulServer::updateDataObject

* added findClassNameEndpoint method, replaced getEndpointAlias()

* getDataFormatter() - find correct endpoint instead of request param ClassName

* Better docblock

Co-Authored-By: Guy Marriott <guy@scopey.co.nz>

* Better docblock

Co-Authored-By: Guy Marriott <guy@scopey.co.nz>

* Return type hint in findClassNameEndpoint method

Co-Authored-By: Guy Marriott <guy@scopey.co.nz>

* renamed endpoint method to resolveEndpoint

* unsanitiseClassName in resolveEndpoint method

* renamed resolveEndpoint to resolveClassName. Take $request as param instead of string

* better docbloc

* Remove unneccesary unsanitiseClassName call

Co-Authored-By: Guy Marriott <guy@scopey.co.nz>

* changed docblocks to satisfy codesniffer

* Drop PHP 5.6 and 7.0 from travis


Co-authored-by: Guy Marriott <guy@scopey.co.nz>
2019-07-15 10:03:51 +12:00
Robbie Averill e0f4e5684f Merge branch '2.1' 2019-06-28 16:25:24 +12:00
Robbie Averill 89c811b295 Merge branch '2.0' into 2.1 2019-06-28 16:24:21 +12:00
Robbie Averill bb45d27869 Update Travis build matrix 2019-06-28 16:13:59 +12:00
Robbie Averill 71865f60a4 Merge branch '2.1' 2019-06-11 14:09:26 +12:00
Robbie Averill a9507a7886 Merge branch '2.0' into 2.1 2019-06-11 14:09:15 +12:00
Robbie Averill 5505f93875 Use trusty dist for Travis builds 2019-06-11 14:09:07 +12:00
Robbie Averill aed6575e89 Merge branch '2.0' into 2.1 2019-06-11 12:05:29 +12:00
Robbie Averill a3319831a8
Merge pull request #1 from silverstripe-security/pulls/2.0/sort-only-on-fields
[CVE-2019-12149] Fixed potential SQL injection vulnerability in RestfulServer
2019-06-11 12:04:42 +12:00
Robbie Averill 284aceddd0 Use trusty in Travis builds 2019-05-29 11:08:55 +12:00
Robbie Averill 165e1d4794
Merge pull request #75 from phptek/issue/70
FIX: Fixes #70 Added extension points for GET requests
2019-05-27 15:29:22 +12:00
Robbie Averill dbb8e18644 [CVE-2019-12149] Fixed potential SQL injection vulnerability in RestfulServer 2019-05-20 15:46:18 +12:00
Robbie Averill b44203b800
DOCS Fix formatting endpoint descriptions
[ci skip]
2019-05-16 10:30:11 +12:00
User for performing fabric deployments 498402389c FIX: Fixes #70 Added extension points for GET requests
- MINOR: Fixed typos
2019-05-07 14:08:32 +12:00
Guy Marriott 57c0597db3
Merge pull request #72 from creative-commoners/pulls/2.1/remove-json-methods
FIX Replace Convert JSON methods with json_* methods, deprecated from SilverStripe 4.4
2018-10-29 11:29:39 +13:00
Robbie Averill 2390698ea9 FIX Replace Convert JSON methods with json_* methods, deprecated from SilverStripe 4.4 2018-10-28 21:39:14 +00:00
Robbie Averill 080ce4015b Merge branch '2.1' 2018-07-26 15:12:19 +12:00
Dylan Wagstaff c8ddec1ecb Add supported module badge to readme (#71) 2018-06-18 10:42:37 +12:00
Robbie Averill 1e09707cc0 Remove obsolete branch alias 2018-06-11 15:36:41 +12:00
Robbie Averill 11ac9d142e Merge branch '2.0' 2018-06-06 10:34:43 +12:00
Russ Michell 9e923d6f9e FIX: Fixes #65 Use Injector to instantiate created objects. (#68) 2018-06-06 09:39:30 +12:00
Russ Michell 8e4fbd0636 FIX: Fixes #63 Conditionally permit additional GET request in POST context. (#64) 2018-05-31 12:11:12 +12:00
Robbie Averill 489f8c576f Merge branch '2.0' 2018-05-25 15:03:11 +12:00
andreaspiening cacf25fb9b Fix infinite redirect after PUT (#62)
* Fix infinite redirect after PUT by changing requestMethod through responsecode
* Fix unit tests to reflect changes
* Use 202 instead of 303
2018-05-08 12:09:02 +12:00
andreaspiening 73c61e7d4c Cast SilverStripe types to appropriate JSON types (#60) 2018-04-19 15:54:43 +12:00
Robbie Averill 9243546b75
Merge pull request #59 from catalyst/jsondataformater-to-not-use-xml
FIX: fix JSONDataFormatter to not convert values to XML
2018-04-17 13:01:09 +12:00
Andreas Piening 4ec6eb4db0 FIX: fix JSONDataFormatter to not convert values to XML 2018-04-17 11:56:21 +12:00
Robbie Averill ee37e6c896
Merge pull request #57 from silverstripe-terraformers/feature/many_many_through_support
Many many through syntax support added.
2018-04-09 15:49:41 +12:00
Mojmir Fendek a60751bd74 Many many through syntax support added. 2018-04-09 12:26:24 +12:00
Robbie Averill 3c1055e2f0
Merge pull request #58 from catalyst/fix-missing-canview-check
FIX: add missing canView check in json
2018-04-09 11:34:20 +12:00
Andreas Piening cbca821c9b FIX: comply with psr-2 2018-04-09 11:27:20 +12:00
Andreas Piening d0149f8995 FIX: add missing canView check in json 2018-04-09 11:20:34 +12:00
Robbie Averill 5f7861e0ac
Merge pull request #51 from silverstripe-terraformers/feature/catch-exception-and-extension-points
Added general Exception catch/response. Added extension points to response methods
2018-04-04 15:34:05 +12:00
Robbie Averill eaef7a5ea4 Merge branch '2.0' 2018-04-04 15:33:03 +12:00
Robbie Averill f54142e661 Remove obsolete branch alias 2018-04-04 15:31:59 +12:00
Robbie Averill bb000254af
Merge pull request #56 from catalyst/sanitise-json-classnames
Sanitise json classnames
2018-04-04 15:19:54 +12:00
Robbie Averill 49f12cb31d
Merge pull request #55 from catalyst/master
FIX: make RestfulServer:: configurable
2018-04-04 15:19:01 +12:00
Andreas Piening 9cfe4f343d Sanitise class name in JSON formatter 2018-04-04 14:54:00 +12:00
Andreas Piening d45a407185 FIX: make RestfulServer:: configurable 2018-04-04 12:54:18 +12:00
Robbie Averill 4581fbf479
Merge pull request #53 from silverstripe-terraformers/feature/api-field-mapping
Add field mapping config
2018-03-23 09:43:17 +13:00
Bernard Hamlin 5c52bf0c53 Alias unit tests 2018-03-09 16:16:15 +13:00
Bernard Hamlin aada3e350f Add field mapping config 2018-03-08 16:27:23 +13:00
cpenny 44c5b45748 Added general Exception catch/response. Added extension points to all response methods. 2018-03-07 11:41:27 +13:00
Robbie Averill c30b72e058
Merge pull request #50 from silverstripe-terraformers/feature/validation-result
SS4: Catch ValidationExceptions and return ValidationResult messages.
2018-03-02 10:47:56 +13:00
cpenny 029ccd0a38 Catch ValidationExceptions and return ValidationResult messages. 2018-03-02 10:39:47 +13:00
Robbie Averill b59d956143
Merge pull request #49 from creative-commoners/pulls/2.0/fix-relation-getter
FIX getFieldsForObj does not return relation classes in hasField() check
2018-02-08 17:11:43 +13:00
Robbie Averill 5b58220367 Cleanup unused class imports 2018-02-08 16:56:21 +13:00
Robbie Averill defdc72bbd FIX getFieldsForObj does not return relation classes in hasField() check 2018-02-08 16:56:07 +13:00
Robbie Averill 9e4b2ff59b
Merge pull request #46 from creative-commoners/pulls/4.0/replace-config-stat-with-get
FIX Replace deprecated Config stat() calls with get()
2017-12-07 23:53:47 +13:00
Raissa North 334553c779 FIX Replace deprecated Config stat() calls with get() 2017-12-07 15:53:11 +13:00
Robbie Averill 852ca334b8
Merge pull request #47 from creative-commoners/pulls/4.0/dataformatter-enhancement
ENHANCEMENT DataFormatter setters return for chaining
2017-12-07 15:19:31 +13:00
Raissa North 823d29f94a ENHANCEMENT DataFormatter setters return for chaining 2017-12-07 10:32:13 +13:00
Robbie Averill 47ec185c12
Merge pull request #42 from creative-commoners/pulls/2.0/towards-two
Upgrade: begin SilverStripe 4 compatiblity update
2017-12-06 13:10:05 +13:00
Dylan Wagstaff 3dda824796 Silence Travis CI complaints about Versioned via require-dev 2017-12-06 11:57:22 +13:00
Dylan Wagstaff 6601b42c4b Upgrade: begin SilverStripe 4 compatiblity update 2017-12-06 10:43:10 +13:00
Robbie Averill e5a757d589 Merge branch '1.0' 2017-11-17 14:59:38 +13:00
Dylan Wagstaff a737f67a13
Merge pull request #41 from creative-commoners/pulls/1.0/fix-total-items-with-canview
FIX Total items count in output respects canView on records
2017-11-17 14:12:47 +13:00
Dylan Wagstaff 4bdd071354
Merge pull request #40 from creative-commoners/pulls/1.0/fix-for-empty-post-body
FIX Return string directly when no body content is provided to put/post methods
2017-11-17 14:10:39 +13:00
Robbie Averill be255c2af1 FIX Total items count in output respects canView on records 2017-11-17 14:09:23 +13:00
Robbie Averill b3fc6803fd FIX Return string directly when no body content is provided to put/post methods 2017-11-17 13:55:08 +13:00
Daniel Hensby 4ba5bf5853
Merge pull request #39 from creative-commoners/pulls/1.0/pass-member
FIX Ensure a Member object is passed to canView etc methods if available
2017-11-06 12:16:33 +00:00
Robbie Averill e54e23ede0 Remove PHP 5.3 from Travis config, shuffle tested SS versions 2017-11-03 10:23:32 +13:00
Robbie Averill cb92696392 FIX Ensure a Member object is passed to canView etc methods if available 2017-11-03 10:20:45 +13:00
Franco Springveldt a12e6d48f5 Remove obsolete branch-alias 2017-08-25 16:15:18 +12:00
Daniel Hensby ba7b5bce1d Merge pull request #38 from creative-commoners/pulls/1/travis-php7
Add PHP7 + SS3.6 build to Travis configuration
2017-06-15 19:16:42 +01:00
Robbie Averill c54b4d345d Add PHP7 + SS3.6 build to Travis configuration 2017-06-15 11:14:22 +12:00
Damian Mooyman f6025eac1d Update changelog for 1.0.5 release 2016-02-04 15:32:17 +13:00
Damian Mooyman 09662b678e Merge pull request #33 from helpfulrobot/update-license-year
Updated license year
2016-01-05 11:20:48 +13:00
helpfulrobot 24bc82b0ac Updated license year 2016-01-01 06:46:47 +13:00
Daniel Hensby 90de9c2260 Merge pull request #26 from helpfulrobot/convert-to-psr-2
Converted to PSR-2
2015-11-24 14:14:21 +00:00
Daniel Hensby 78374ee57c Merge pull request #25 from helpfulrobot/add-standard-scrutinizer-config
Added standard Scrutinizer config
2015-11-23 17:52:13 +00:00
Daniel Hensby 5e28d7bc25 Merge pull request #32 from helpfulrobot/add-standard-code-of-conduct
Added standard code of conduct
2015-11-23 17:45:54 +00:00
helpfulrobot 90a1f63ae6 Added standard code of conduct 2015-11-21 20:16:14 +13:00
helpfulrobot e69554810d Added standard Scrutinizer config 2015-11-21 19:31:50 +13:00
helpfulrobot 96f868dbf5 Converted to PSR-2 2015-11-21 19:21:34 +13:00
Daniel Hensby de3dc96d39 Merge pull request #27 from helpfulrobot/add-standard-travis-config
Added standard Travis config
2015-11-20 15:49:13 +00:00
Daniel Hensby c05dc87578 Merge pull request #28 from helpfulrobot/add-standard-editor-config
Added standard editor config
2015-11-20 15:00:13 +00:00
Daniel Hensby 71a0ba660b Merge pull request #29 from helpfulrobot/add-standard-license
Added standard license
2015-11-19 17:22:09 +00:00
Daniel Hensby e12bf7c63e Merge pull request #30 from helpfulrobot/add-license-to-composer
Added license to composer.json
2015-11-19 11:58:58 +00:00
Daniel Hensby 3b16d0c9a3 Merge pull request #31 from helpfulrobot/add-standard-git-attributes
Added standard git attributes
2015-11-19 10:41:16 +00:00
helpfulrobot 4a361e0cc6 Added standard git attributes 2015-11-19 19:13:03 +13:00
helpfulrobot c7362296da Added license to composer.json 2015-11-19 18:52:57 +13:00
helpfulrobot 53eaa3a6fa Added standard license 2015-11-19 18:31:43 +13:00
helpfulrobot 775b2c6a21 Added standard Travis config 2015-11-19 14:20:16 +13:00
helpfulrobot 94315960a6 Added standard editor config 2015-11-19 13:26:20 +13:00
46 changed files with 3650 additions and 1523 deletions

View File

@ -1,24 +1,17 @@
# For more information about the properties used in
# this file, please see the EditorConfig documentation:
# http://editorconfig.org/
# For more information about the properties used in this file,
# please see the EditorConfig documentation:
# http://editorconfig.org
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.yml]
[{*.yml,package.json,*.js}]
indent_size = 2
indent_style = space
[{.travis.yml,package.json}]
# The indent size used in the `package.json` file cannot be changed
# The indent size used in the package.json file cannot be changed:
# https://github.com/npm/npm/pull/3180#issuecomment-16336516
indent_size = 2
indent_style = space

7
.gitattributes vendored Normal file
View File

@ -0,0 +1,7 @@
/tests export-ignore
/docs export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.travis.yml export-ignore
/.scrutinizer.yml export-ignore
/codecov.yml export-ignore

11
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,11 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
ci:
name: CI
uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1

16
.github/workflows/dispatch-ci.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Dispatch CI
on:
# At 1:10 PM UTC, only on Friday and Saturday
schedule:
- cron: '10 13 * * 5,6'
jobs:
dispatch-ci:
name: Dispatch CI
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Dispatch CI
uses: silverstripe/gha-dispatch-ci@v1

17
.github/workflows/keepalive.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: Keepalive
on:
workflow_dispatch:
# The 4th of every month at 10:50am UTC
schedule:
- cron: '50 10 4 * *'
jobs:
keepalive:
name: Keepalive
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Keepalive
uses: silverstripe/gha-keepalive@v1

View File

@ -1,31 +0,0 @@
language: php
php:
- 5.3
sudo: false
env:
- DB=MYSQL CORE_RELEASE=3.1
matrix:
include:
- php: 5.4
env: DB=PGSQL CORE_RELEASE=3.2
- php: 5.4
env: DB=SQLITE CORE_RELEASE=3.2
- php: 5.5
env: DB=MYSQL CORE_RELEASE=3
- php: 5.6
env: DB=MYSQL CORE_RELEASE=3.2
before_script:
- pear -q install --onlyreqdeps pear/PHP_CodeSniffer
- phpenv rehash
- git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support
- php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss
- cd ~/builds/ss
script:
- phpunit restfulserver/tests/
- phpcs --encoding=utf-8 --tab-width=4 --standard=restfulserver/tests/phpcs -np restfulserver

15
.upgrade.yml Normal file
View File

@ -0,0 +1,15 @@
mappings:
BasicRestfulAuthenticator: SilverStripe\RestfulServer\BasicRestfulAuthenticator
RestfulServer: SilverStripe\RestfulServer\RestfulServer
RestfulServerItem: SilverStripe\RestfulServer\RestfulServerItem
RestfulServerList: SilverStripe\RestfulServer\RestfulServerList
RestfulServerTest: SilverStripe\RestfulServer\Tests\RestfulServerTest
RestfulServerTestAuthor: SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor
RestfulServerTestAuthorRating: SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthorRating
RestfulServerTestComment: SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestComment
RestfulServerTestPage: SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage
RestfulServerTestSecretThing: SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestSecretThing
DataFormatter: SilverStripe\RestfulServer\DataFormatter
FormEncodedDataFormatter: SilverStripe\RestfulServer\DataFormatter\FormEncodedDataFormatter
JSONDataFormatter: SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter
XMLDataFormatter: SilverStripe\RestfulServer\DataFormatter\XMLDataFormatter

24
LICENSE
View File

@ -1,24 +0,0 @@
* Copyright (c) 2012, Silverstripe Ltd.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the name of the <organization> nor the
* names of its contributors may be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY Silverstripe Ltd. ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL Silverstripe Ltd. BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

113
README.md
View File

@ -1,39 +1,100 @@
# SilverStripe RestfulServer Module
# Silverstripe RestfulServer Module
[![Build Status](https://secure.travis-ci.org/silverstripe/silverstripe-restfulserver.png)](http://travis-ci.org/silverstripe/silverstripe-restfulserver)
[![CI](https://github.com/silverstripe/silverstripe-restfulserver/actions/workflows/ci.yml/badge.svg)](https://github.com/silverstripe/silverstripe-restfulserver/actions/workflows/ci.yml)
[![Silverstripe supported module](https://img.shields.io/badge/silverstripe-supported-0071C4.svg)](https://www.silverstripe.org/software/addons/silverstripe-commercially-supported-module-list/)
## Overview
This class gives your application a RESTful API. All you have to do is define static $api_access = true on
the appropriate DataObjects. You will need to ensure that all of your data manipulation and security is defined in
This class gives your application a RESTful API. All you have to do is set the `api_access` configuration option to `true`
on the appropriate DataObjects. You will need to ensure that all of your data manipulation and security is defined in
your model layer (ie, the DataObject classes) and not in your Controllers. This is the recommended design for SilverStripe
applications.
## Requirements
* SilverStripe 3.0 or newer
* Silverstripe 4.0 or higher
For a Silverstripe 3.x compatible version of this module, please see the [1.0 branch, or 1.x release line](https://github.com/silverstripe/silverstripe-restfulserver/tree/1.0#readme).
## Configuration
Enabling restful access on a model will also enable a SOAP API, see `SOAPModelAccess`.
Example DataObject with simple api access, giving full access to all object properties and relations,
Example DataObject with simple API access, giving full access to all object properties and relations,
unless explicitly controlled through model permissions.
class Article extends DataObject {
static $db = array('Title'=>'Text','Published'=>'Boolean');
static $api_access = true;
}
```php
namespace Vendor\Project;
Example DataObject with advanced api access, limiting viewing and editing to Title attribute only:
use SilverStripe\ORM\DataObject;
class Article extends DataObject {
static $db = array('Title'=>'Text','Published'=>'Boolean');
static $api_access = array(
'view' => array('Title'),
'edit' => array('Title'),
);
}
class Article extends DataObject {
private static $db = [
'Title'=>'Text',
'Published'=>'Boolean'
];
private static $api_access = true;
}
```
Example DataObject with advanced API access, limiting viewing and editing to Title attribute only:
```php
namespace Vendor\Project;
use SilverStripe\ORM\DataObject;
class Article extends DataObject {
private static $db = [
'Title'=>'Text',
'Published'=>'Boolean'
];
private static $api_access = [
'view' => ['Title'],
'edit' => ['Title']
];
}
```
Example DataObject field mapping, allows aliasing fields so that public requests and responses display different field names:
```php
namespace Vendor\Project;
use SilverStripe\ORM\DataObject;
class Article extends DataObject {
private static $db = [
'Title'=>'Text',
'Published'=>'Boolean'
];
private static $api_access = [
'view' => ['Title', 'Content'],
];
private static $api_field_mapping = [
'customTitle' => 'Title',
];
}
```
Given a dataobject with values:
```yml
ID: 12
Title: Title Value
Content: Content value
```
which when requesting with the url `/api/v1/Vendor-Project-Article/12?fields=customTitle,Content` and `Accept: application/json` the response will look like:
```Javascript
{
"customTitle": "Title Value",
"Content": "Content value"
}
```
Similarly, `PUT` or `POST` requests will have fields transformed from the alias name to the DB field name.
## Supported operations
@ -45,9 +106,9 @@ Example DataObject with advanced api access, limiting viewing and editing to Tit
- `PUT /api/v1/(ClassName)/(ID)/(Relation)` - updates a relation, replacing the existing record(s) (NOT IMPLEMENTED YET)
- `POST /api/v1/(ClassName)/(ID)/(Relation)` - updates a relation, appending to the existing record(s) (NOT IMPLEMENTED YET)
- DELETE /api/v1/(ClassName)/(ID) - deletes a database record (NOT IMPLEMENTED YET)
- DELETE /api/v1/(ClassName)/(ID)/(Relation)/(ForeignID) - remove the relationship between two database records, but don't actually delete the foreign object (NOT IMPLEMENTED YET)
- POST /api/v1/(ClassName)/(ID)/(MethodName) - executes a method on the given object (e.g, publish)
- `DELETE /api/v1/(ClassName)/(ID)` - deletes a database record (NOT IMPLEMENTED YET)
- `DELETE /api/v1/(ClassName)/(ID)/(Relation)/(ForeignID)` - remove the relationship between two database records, but don't actually delete the foreign object (NOT IMPLEMENTED YET)
- `POST /api/v1/(ClassName)/(ID)/(MethodName)` - executes a method on the given object (e.g, publish)
## Search
@ -66,7 +127,7 @@ to the url, e.g. /api/v1/(ClassName)/?Title=mytitle.
## Access control
Access control is implemented through the usual Member system with Basicauth authentication only.
Access control is implemented through the usual Member system with BasicAuth authentication only.
By default, you have to bear the ADMIN permission to retrieve or send any data.
You should override the following built-in methods to customize permission control on a
class- and object-level:
@ -76,7 +137,7 @@ class- and object-level:
- `DataObject::canDelete()`
- `DataObject::canCreate()`
See `DataObject` documentation for further details.
See `SilverStripe\ORM\DataObject` documentation for further details.
You can specify the character-encoding for any input on the HTTP Content-Type.
At the moment, only UTF-8 is supported. All output is made in UTF-8 regardless of Accept headers.
At the moment, only UTF-8 is supported. All output is made in UTF-8 regardless of Accept headers.

View File

View File

@ -1,10 +1,6 @@
---
Name: restfulserverroutes
After:
- '#rootroutes'
- '#modelascontrollerroutes'
---
Director:
SilverStripe\Control\Director:
rules:
'api/v1/live': 'VersionedRestfulServer'
'api/v1': 'RestfulServer'
'api/v1': 'SilverStripe\RestfulServer\RestfulServer'

View File

@ -1,10 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.4]
* Changelog added.
* Include 3.2 and php 5.6 in tests

1
code-of-conduct.md Normal file
View File

@ -0,0 +1 @@
When having discussions about this module in issues or pull request please adhere to the [SilverStripe Community Code of Conduct](https://docs.silverstripe.org/en/contributing/code_of_conduct).

View File

@ -1,39 +0,0 @@
<?php
/**
* A simple authenticator for the Restful server.
*
* This allows users to be authenticated against that RestfulServer using their
* login details, however they will be passed 'in the open' and will require the
* application accessing the RestfulServer to store logins in plain text (or in
* decrytable form)
*/
class BasicRestfulAuthenticator {
/**
* The authenticate function
*
* Takes the basic auth details and attempts to log a user in from the DB
*
* @return Member|false The Member object, or false if no member
*/
public static function authenticate() {
//if there is no username or password, break
if(!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) return false;
//Attempt to authenticate with the default authenticator for the site
$authClass = Authenticator::get_default_authenticator();
$member = $authClass::authenticate(array(
'Email' => $_SERVER['PHP_AUTH_USER'],
'Password' => $_SERVER['PHP_AUTH_PW'],
));
//Log the member in and return the member, if they were found
if($member) {
$member->LogIn(false);
return $member;
}
return false;
}
}

View File

@ -1,627 +0,0 @@
<?php
/**
* Generic RESTful server, which handles webservice access to arbitrary DataObjects.
* Relies on serialization/deserialization into different formats provided
* by the DataFormatter APIs in core.
*
* @todo Finish RestfulServer_Item and RestfulServer_List implementation and re-enable $url_handlers
* @todo Implement PUT/POST/DELETE for relations
* @todo Access-Control for relations (you might be allowed to view Members and Groups,
* but not their relation with each other)
* @todo Make SearchContext specification customizeable for each class
* @todo Allow for range-searches (e.g. on Created column)
* @todo Filter relation listings by $api_access and canView() permissions
* @todo Exclude relations when "fields" are specified through URL (they should be explicitly
* requested in this case)
* @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in
* SiteTree/Versioned/Hierarchy
* @todo URL parameter namespacing for search-fields, limit, fields, add_fields
* (might all be valid dataobject properties)
* e.g. you wouldn't be able to search for a "limit" property on your subclass as
* its overlayed with the search logic
* @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
* @todo Access to extendable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
* @todo Respect $api_access array notation in search contexts
*
* @package framework
* @subpackage api
*/
class RestfulServer extends Controller {
static $url_handlers = array(
'$ClassName/$ID/$Relation' => 'handleAction'
#'$ClassName/#ID' => 'handleItem',
#'$ClassName' => 'handleList',
);
protected static $api_base = "api/v1/";
protected static $authenticator = 'BasicRestfulAuthenticator';
/**
* If no extension is given in the request, resolve to this extension
* (and subsequently the {@link self::$default_mimetype}.
*
* @var string
*/
public static $default_extension = "xml";
/**
* If no extension is given, resolve the request to this mimetype.
*
* @var string
*/
protected static $default_mimetype = "text/xml";
/**
* @uses authenticate()
* @var Member
*/
protected $member;
public static $allowed_actions = array(
'index'
);
/*
function handleItem($request) {
return new RestfulServer_Item(DataObject::get_by_id($request->param("ClassName"), $request->param("ID")));
}
function handleList($request) {
return new RestfulServer_List(DataObject::get($request->param("ClassName"),""));
}
*/
function init() {
/* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
* to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
* TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
*/
if (class_exists('SiteTree')) singleton('SiteTree')->extend('modelascontrollerInit', $this);
parent::init();
}
/**
* This handler acts as the switchboard for the controller.
* Since no $Action url-param is set, all requests are sent here.
*/
function index() {
if(!isset($this->urlParams['ClassName'])) return $this->notFound();
$className = $this->urlParams['ClassName'];
$id = (isset($this->urlParams['ID'])) ? $this->urlParams['ID'] : null;
$relation = (isset($this->urlParams['Relation'])) ? $this->urlParams['Relation'] : null;
// Check input formats
if(!class_exists($className)) return $this->notFound();
if($id && !is_numeric($id)) return $this->notFound();
if(
$relation
&& !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
) {
return $this->notFound();
}
// if api access is disabled, don't proceed
$apiAccess = singleton($className)->stat('api_access');
if(!$apiAccess) return $this->permissionFailure();
// authenticate through HTTP BasicAuth
$this->member = $this->authenticate();
// handle different HTTP verbs
if($this->request->isGET() || $this->request->isHEAD()) {
return $this->getHandler($className, $id, $relation);
}
if($this->request->isPOST()) {
return $this->postHandler($className, $id, $relation);
}
if($this->request->isPUT()) {
return $this->putHandler($className, $id, $relation);
}
if($this->request->isDELETE()) {
return $this->deleteHandler($className, $id, $relation);
}
// if no HTTP verb matches, return error
return $this->methodNotAllowed();
}
/**
* Handler for object read.
*
* The data object will be returned in the following format:
*
* <ClassName>
* <FieldName>Value</FieldName>
* ...
* <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
* ...
* <HasManyRelName>
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* </HasManyRelName>
* ...
* <ManyManyRelName>
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* </ManyManyRelName>
* </ClassName>
*
* Access is controlled by two variables:
*
* - static $api_access must be set. This enables the API on a class by class basis
* - $obj->canView() must return true. This lets you implement record-level security
*
* @todo Access checking
*
* @param String $className
* @param Int $id
* @param String $relation
* @return String The serialized representation of the requested object(s) - usually XML or JSON.
*/
protected function getHandler($className, $id, $relationName) {
$sort = '';
if($this->request->getVar('sort')) {
$dir = $this->request->getVar('dir');
$sort = array($this->request->getVar('sort') => ($dir ? $dir : 'ASC'));
}
$limit = array(
'start' => $this->request->getVar('start'),
'limit' => $this->request->getVar('limit')
);
$params = $this->request->getVars();
$responseFormatter = $this->getResponseDataFormatter($className);
if(!$responseFormatter) return $this->unsupportedMediaType();
// $obj can be either a DataObject or a SS_List,
// depending on the request
if($id) {
// Format: /api/v1/<MyClass>/<ID>
$obj = $this->getObjectQuery($className, $id, $params)->First();
if(!$obj) return $this->notFound();
if(!$obj->canView()) return $this->permissionFailure();
// Format: /api/v1/<MyClass>/<ID>/<Relation>
if($relationName) {
$obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
if(!$obj) return $this->notFound();
// TODO Avoid creating data formatter again for relation class (see above)
$responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
}
} else {
// Format: /api/v1/<MyClass>
$obj = $this->getObjectsQuery($className, $params, $sort, $limit);
}
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$rawFields = $this->request->getVar('fields');
$fields = $rawFields ? explode(',', $rawFields) : null;
if($obj instanceof SS_List) {
$responseFormatter->setTotalSize($obj->dataQuery()->query()->unlimitedRowCount());
$objs = new ArrayList($obj->toArray());
foreach($objs as $obj) if(!$obj->canView()) $objs->remove($obj);
return $responseFormatter->convertDataObjectSet($objs, $fields);
} else if(!$obj) {
$responseFormatter->setTotalSize(0);
return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
} else {
return $responseFormatter->convertDataObject($obj, $fields);
}
}
/**
* Uses the default {@link SearchContext} specified through
* {@link DataObject::getDefaultSearchContext()} to augument
* an existing query object (mostly a component query from {@link DataObject})
* with search clauses.
*
* @todo Allow specifying of different searchcontext getters on model-by-model basis
*
* @param string $className
* @param array $params
* @return SS_List
*/
protected function getSearchQuery($className, $params = null, $sort = null,
$limit = null, $existingQuery = null
) {
if(singleton($className)->hasMethod('getRestfulSearchContext')) {
$searchContext = singleton($className)->{'getRestfulSearchContext'}();
} else {
$searchContext = singleton($className)->getDefaultSearchContext();
}
return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
}
/**
* Returns a dataformatter instance based on the request
* extension or mimetype. Falls back to {@link self::$default_extension}.
*
* @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
* @param String Classname of a DataObject
* @return DataFormatter
*/
protected function getDataFormatter($includeAcceptHeader = false, $className = null) {
$extension = $this->request->getExtension();
$contentTypeWithEncoding = $this->request->getHeader('Content-Type');
preg_match('/([^;]*)/',$contentTypeWithEncoding, $contentTypeMatches);
$contentType = $contentTypeMatches[0];
$accept = $this->request->getHeader('Accept');
$mimetypes = $this->request->getAcceptMimetypes();
if(!$className) $className = $this->urlParams['ClassName'];
// get formatter
if(!empty($extension)) {
$formatter = DataFormatter::for_extension($extension);
}elseif($includeAcceptHeader && !empty($accept) && $accept != '*/*') {
$formatter = DataFormatter::for_mimetypes($mimetypes);
if(!$formatter) $formatter = DataFormatter::for_extension(self::$default_extension);
} elseif(!empty($contentType)) {
$formatter = DataFormatter::for_mimetype($contentType);
} else {
$formatter = DataFormatter::for_extension(self::$default_extension);
}
if(!$formatter) return false;
// set custom fields
if($customAddFields = $this->request->getVar('add_fields')) {
$formatter->setCustomAddFields(explode(',',$customAddFields));
}
if($customFields = $this->request->getVar('fields')) {
$formatter->setCustomFields(explode(',',$customFields));
}
$formatter->setCustomRelations($this->getAllowedRelations($className));
$apiAccess = singleton($className)->stat('api_access');
if(is_array($apiAccess)) {
$formatter->setCustomAddFields(
array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
);
if($formatter->getCustomFields()) {
$formatter->setCustomFields(
array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
);
} else {
$formatter->setCustomFields((array)$apiAccess['view']);
}
if($formatter->getCustomRelations()) {
$formatter->setCustomRelations(
array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
);
} else {
$formatter->setCustomRelations((array)$apiAccess['view']);
}
}
// set relation depth
$relationDepth = $this->request->getVar('relationdepth');
if(is_numeric($relationDepth)) $formatter->relationDepth = (int)$relationDepth;
return $formatter;
}
/**
* @param String Classname of a DataObject
* @return DataFormatter
*/
protected function getRequestDataFormatter($className = null) {
return $this->getDataFormatter(false, $className);
}
/**
* @param String Classname of a DataObject
* @return DataFormatter
*/
protected function getResponseDataFormatter($className = null) {
return $this->getDataFormatter(true, $className);
}
/**
* Handler for object delete
*/
protected function deleteHandler($className, $id) {
$obj = DataObject::get_by_id($className, $id);
if(!$obj) return $this->notFound();
if(!$obj->canDelete()) return $this->permissionFailure();
$obj->delete();
$this->getResponse()->setStatusCode(204); // No Content
return true;
}
/**
* Handler for object write
*/
protected function putHandler($className, $id) {
$obj = DataObject::get_by_id($className, $id);
if(!$obj) return $this->notFound();
if(!$obj->canEdit()) return $this->permissionFailure();
$reqFormatter = $this->getRequestDataFormatter($className);
if(!$reqFormatter) return $this->unsupportedMediaType();
$responseFormatter = $this->getResponseDataFormatter($className);
if(!$responseFormatter) return $this->unsupportedMediaType();
$obj = $this->updateDataObject($obj, $reqFormatter);
$this->getResponse()->setStatusCode(200); // Success
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
// Append the default extension for the output format to the Location header
// or else we'll use the default (XML)
$types = $responseFormatter->supportedExtensions();
$type = '';
if (count($types)) {
$type = ".{$types[0]}";
}
$objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID" . $type);
$this->getResponse()->addHeader('Location', $objHref);
return $responseFormatter->convertDataObject($obj);
}
/**
* Handler for object append / method call.
*
* @todo Posting to an existing URL (without a relation)
* current resolves in creatig a new element,
* rather than a "Conflict" message.
*/
protected function postHandler($className, $id, $relation) {
if($id) {
if(!$relation) {
$this->response->setStatusCode(409);
return 'Conflict';
}
$obj = DataObject::get_by_id($className, $id);
if(!$obj) return $this->notFound();
if(!$obj->hasMethod($relation)) {
return $this->notFound();
}
if(!$obj->stat('allowed_actions') || !in_array($relation, $obj->stat('allowed_actions'))) {
return $this->permissionFailure();
}
$obj->$relation();
$this->getResponse()->setStatusCode(204); // No Content
return true;
} else {
if(!singleton($className)->canCreate()) return $this->permissionFailure();
$obj = new $className();
$reqFormatter = $this->getRequestDataFormatter($className);
if(!$reqFormatter) return $this->unsupportedMediaType();
$responseFormatter = $this->getResponseDataFormatter($className);
$obj = $this->updateDataObject($obj, $reqFormatter);
$this->getResponse()->setStatusCode(201); // Created
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
// Append the default extension for the output format to the Location header
// or else we'll use the default (XML)
$types = $responseFormatter->supportedExtensions();
$type = '';
if (count($types)) {
$type = ".{$types[0]}";
}
$objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID" . $type);
$this->getResponse()->addHeader('Location', $objHref);
return $responseFormatter->convertDataObject($obj);
}
}
/**
* Converts either the given HTTP Body into an array
* (based on the DataFormatter instance), or returns
* the POST variables.
* Automatically filters out certain critical fields
* that shouldn't be set by the client (e.g. ID).
*
* @param DataObject $obj
* @param DataFormatter $formatter
* @return DataObject The passed object
*/
protected function updateDataObject($obj, $formatter) {
// if neither an http body nor POST data is present, return error
$body = $this->request->getBody();
if(!$body && !$this->request->postVars()) {
$this->getResponse()->setStatusCode(204); // No Content
return 'No Content';
}
if(!empty($body)) {
$data = $formatter->convertStringToArray($body);
} else {
// assume application/x-www-form-urlencoded which is automatically parsed by PHP
$data = $this->request->postVars();
}
// @todo Disallow editing of certain keys in database
$data = array_diff_key($data, array('ID','Created'));
$apiAccess = singleton($this->urlParams['ClassName'])->stat('api_access');
if(is_array($apiAccess) && isset($apiAccess['edit'])) {
$data = array_intersect_key($data, array_combine($apiAccess['edit'],$apiAccess['edit']));
}
$obj->update($data);
$obj->write();
return $obj;
}
/**
* Gets a single DataObject by ID,
* through a request like /api/v1/<MyClass>/<MyID>
*
* @param string $className
* @param int $id
* @param array $params
* @return DataList
*/
protected function getObjectQuery($className, $id, $params) {
return DataList::create($className)->byIDs(array($id));
}
/**
* @param DataObject $obj
* @param array $params
* @param int|array $sort
* @param int|array $limit
* @return SQLQuery
*/
protected function getObjectsQuery($className, $params, $sort, $limit) {
return $this->getSearchQuery($className, $params, $sort, $limit);
}
/**
* @param DataObject $obj
* @param array $params
* @param int|array $sort
* @param int|array $limit
* @param string $relationName
* @return SQLQuery|boolean
*/
protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName) {
// The relation method will return a DataList, that getSearchQuery subsequently manipulates
if($obj->hasMethod($relationName)) {
if($relationClass = $obj->has_one($relationName)) {
$joinField = $relationName . 'ID';
$list = DataList::create($relationClass)->byIDs(array($obj->$joinField));
} else {
$list = $obj->$relationName();
}
$apiAccess = singleton($list->dataClass())->stat('api_access');
if(!$apiAccess) return false;
return $this->getSearchQuery($list->dataClass(), $params, $sort, $limit, $list);
}
}
protected function permissionFailure() {
// return a 401
$this->getResponse()->setStatusCode(401);
$this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "You don't have access to this item through the API.";
}
protected function notFound() {
// return a 404
$this->getResponse()->setStatusCode(404);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "That object wasn't found";
}
protected function methodNotAllowed() {
$this->getResponse()->setStatusCode(405);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "Method Not Allowed";
}
protected function unsupportedMediaType() {
$this->response->setStatusCode(415); // Unsupported Media Type
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "Unsupported Media Type";
}
/**
* A function to authenticate a user
*
* @return Member|false the logged in member
*/
protected function authenticate() {
$authClass = self::config()->authenticator;
return $authClass::authenticate();
}
/**
* Return only relations which have $api_access enabled.
* @todo Respect field level permissions once they are available in core
*
* @param string $class
* @param Member $member
* @return array
*/
protected function getAllowedRelations($class, $member = null) {
$allowedRelations = array();
$obj = singleton($class);
$relations = (array)$obj->has_one() + (array)$obj->has_many() + (array)$obj->many_many();
if($relations) foreach($relations as $relName => $relClass) {
if(singleton($relClass)->stat('api_access')) {
$allowedRelations[] = $relName;
}
}
return $allowedRelations;
}
}
/**
* Restful server handler for a SS_List
*
* @package framework
* @subpackage api
*/
class RestfulServer_List {
static $url_handlers = array(
'#ID' => 'handleItem',
);
function __construct($list) {
$this->list = $list;
}
function handleItem($request) {
return new RestulServer_Item($this->list->getById($request->param('ID')));
}
}
/**
* Restful server handler for a single DataObject
*
* @package framework
* @subpackage api
*/
class RestfulServer_Item {
static $url_handlers = array(
'$Relation' => 'handleRelation',
);
function __construct($item) {
$this->item = $item;
}
function handleRelation($request) {
$funcName = $request('Relation');
$relation = $this->item->$funcName();
if($relation instanceof SS_List) return new RestfulServer_List($relation);
else return new RestfulServer_Item($relation);
}
}

1
codecov.yml Normal file
View File

@ -0,0 +1 @@
comment: false

View File

@ -1,27 +1,39 @@
{
"name": "silverstripe/restfulserver",
"description": "Add a RESTful API to your SilverStripe application",
"type": "silverstripe-module",
"keywords": ["silverstripe", "rest", "api"],
"authors": [
{
"name": "Hamish Friedlander",
"email": "hamish@silverstripe.com"
},
{
"name": "Sam Minnee",
"email": "sam@silverstripe.com"
}
],
"require":
{
"silverstripe/framework": "3.*"
},
"extra":
{
"branch-alias":
{
"dev-master": "1.0.x-dev"
}
}
}
"name": "silverstripe/restfulserver",
"description": "Add a RESTful API to your SilverStripe application",
"type": "silverstripe-vendormodule",
"keywords": [
"silverstripe",
"rest",
"api"
],
"license": "BSD-3-Clause",
"authors": [
{
"name": "Hamish Friedlander",
"email": "hamish@silverstripe.com"
},
{
"name": "Sam Minnee",
"email": "sam@silverstripe.com"
}
],
"require": {
"php": "^7.4 || ^8.0",
"silverstripe/framework": "^4.10"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.0",
"silverstripe/versioned": "^1"
},
"autoload": {
"psr-4": {
"SilverStripe\\RestfulServer\\": "src",
"SilverStripe\\RestfulServer\\Tests\\": "tests"
}
},
"extra": [],
"prefer-stable": true,
"minimum-stability": "dev"
}

12
license.md Normal file
View File

@ -0,0 +1,12 @@
Copyright (c) 2017, SilverStripe Limited
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

12
phpcs.xml.dist Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="SilverStripe">
<description>CodeSniffer ruleset for SilverStripe coding conventions.</description>
<file>src</file>
<file>tests</file>
<rule ref="PSR2" >
<!-- Current exclusions -->
<exclude name="PSR1.Methods.CamelCapsMethodName" />
</rule>
</ruleset>

16
phpunit.xml.dist Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/silverstripe/framework/tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="Default">
<directory>tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist addUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src/</directory>
<exclude>
<directory suffix=".php">tests/</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

View File

@ -0,0 +1,49 @@
<?php
namespace SilverStripe\RestfulServer;
use SilverStripe\Security\Authenticator;
use SilverStripe\Control\Controller;
use SilverStripe\Security\Security;
/**
* A simple authenticator for the Restful server.
*
* This allows users to be authenticated against that RestfulServer using their
* login details, however they will be passed 'in the open' and will require the
* application accessing the RestfulServer to store logins in plain text (or in
* decrytable form)
*/
class BasicRestfulAuthenticator
{
/**
* The authenticate function
*
* Takes the basic auth details and attempts to log a user in from the DB
*
* @return Member|false The Member object, or false if no member
*/
public static function authenticate()
{
//if there is no username or password, fail
if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) {
return null;
}
// With a valid user and password, check the password is correct
$data = [
'Email' => $_SERVER['PHP_AUTH_USER'],
'Password' => $_SERVER['PHP_AUTH_PW'],
];
$request = Controller::curr()->getRequest();
$authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::LOGIN);
$member = null;
foreach ($authenticators as $authenticator) {
$member = $authenticator->authenticate($data, $request);
if ($member) {
break;
}
}
return $member;
}
}

462
src/DataFormatter.php Normal file
View File

@ -0,0 +1,462 @@
<?php
namespace SilverStripe\RestfulServer;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\ORM\SS_List;
/**
* A DataFormatter object handles transformation of data from SilverStripe model objects to a particular output
* format, and vice versa. This is most commonly used in developing RESTful APIs.
*/
abstract class DataFormatter
{
use Configurable;
/**
* Set priority from 0-100.
* If multiple formatters for the same extension exist,
* we select the one with highest priority.
*
* @var int
*/
private static $priority = 50;
/**
* Follow relations for the {@link DataObject} instances
* ($has_one, $has_many, $many_many).
* Set to "0" to disable relation output.
*
* @todo Support more than one nesting level
*
* @var int
*/
public $relationDepth = 1;
/**
* Allows overriding of the fields which are rendered for the
* processed dataobjects. By default, this includes all
* fields in {@link DataObject::inheritedDatabaseFields()}.
*
* @var array
*/
protected $customFields = null;
/**
* Allows addition of fields
* (e.g. custom getters on a DataObject)
*
* @var array
*/
protected $customAddFields = null;
/**
* Allows to limit or add relations.
* Only use in combination with {@link $relationDepth}.
* By default, all relations will be shown.
*
* @var array
*/
protected $customRelations = null;
/**
* Fields which should be expicitly excluded from the export.
* Comes in handy for field-level permissions.
* Will overrule both {@link $customAddFields} and {@link $customFields}
*
* @var array
*/
protected $removeFields = null;
/**
* Specifies the mimetype in which all strings
* returned from the convert*() methods should be used,
* e.g. "text/xml".
*
* @var string
*/
protected $outputContentType = null;
/**
* Used to set totalSize properties on the output
* of {@link convertDataObjectSet()}, shows the
* total number of records without the "limit" and "offset"
* GET parameters. Useful to implement pagination.
*
* @var int
*/
protected $totalSize;
/**
* Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
* kills both requests (i.e. URIs) and XML (invalid character in a tag name)
* So we'll replace them with a hyphen (-), as it's also unambiguious
* in both cases (invalid in a php class name, and safe in an xml tag name)
*
* @param string $classname
* @return string 'escaped' class name
*/
protected function sanitiseClassName($className)
{
return str_replace('\\', '-', $className ?? '');
}
/**
* Get a DataFormatter object suitable for handling the given file extension.
*
* @param string $extension
* @return DataFormatter
*/
public static function for_extension($extension)
{
$classes = ClassInfo::subclassesFor(DataFormatter::class);
array_shift($classes);
$sortedClasses = [];
foreach ($classes as $class) {
$sortedClasses[$class] = Config::inst()->get($class, 'priority');
}
arsort($sortedClasses);
foreach ($sortedClasses as $className => $priority) {
$formatter = new $className();
if (in_array($extension, $formatter->supportedExtensions() ?? [])) {
return $formatter;
}
}
}
/**
* Get formatter for the first matching extension.
*
* @param array $extensions
* @return DataFormatter
*/
public static function for_extensions($extensions)
{
foreach ($extensions as $extension) {
if ($formatter = self::for_extension($extension)) {
return $formatter;
}
}
return false;
}
/**
* Get a DataFormatter object suitable for handling the given mimetype.
*
* @param string $mimeType
* @return DataFormatter
*/
public static function for_mimetype($mimeType)
{
$classes = ClassInfo::subclassesFor(DataFormatter::class);
array_shift($classes);
$sortedClasses = [];
foreach ($classes as $class) {
$sortedClasses[$class] = Config::inst()->get($class, 'priority');
}
arsort($sortedClasses);
foreach ($sortedClasses as $className => $priority) {
$formatter = new $className();
if (in_array($mimeType, $formatter->supportedMimeTypes() ?? [])) {
return $formatter;
}
}
}
/**
* Get formatter for the first matching mimetype.
* Useful for HTTP Accept headers which can contain
* multiple comma-separated mimetypes.
*
* @param array $mimetypes
* @return DataFormatter
*/
public static function for_mimetypes($mimetypes)
{
foreach ($mimetypes as $mimetype) {
if ($formatter = self::for_mimetype($mimetype)) {
return $formatter;
}
}
return false;
}
/**
* @param array $fields
* @return $this
*/
public function setCustomFields($fields)
{
$this->customFields = $fields;
return $this;
}
/**
* @return array
*/
public function getCustomFields()
{
return $this->customFields;
}
/**
* @param array $fields
* @return $this
*/
public function setCustomAddFields($fields)
{
$this->customAddFields = $fields;
return $this;
}
/**
* @param array $relations
* @return $this
*/
public function setCustomRelations($relations)
{
$this->customRelations = $relations;
return $this;
}
/**
* @return array
*/
public function getCustomRelations()
{
return $this->customRelations;
}
/**
* @return array
*/
public function getCustomAddFields()
{
return $this->customAddFields;
}
/**
* @param array $fields
* @return $this
*/
public function setRemoveFields($fields)
{
$this->removeFields = $fields;
return $this;
}
/**
* @return array
*/
public function getRemoveFields()
{
return $this->removeFields;
}
/**
* @return string
*/
public function getOutputContentType()
{
return $this->outputContentType;
}
/**
* @param int $size
* @return $this
*/
public function setTotalSize($size)
{
$this->totalSize = (int)$size;
return $this;
}
/**
* @return int
*/
public function getTotalSize()
{
return $this->totalSize;
}
/**
* Returns all fields on the object which should be shown
* in the output. Can be customised through {@link self::setCustomFields()}.
*
* @todo Allow for custom getters on the processed object (currently filtered through inheritedDatabaseFields)
* @todo Field level permission checks
*
* @param DataObject $obj
* @return array
*/
protected function getFieldsForObj($obj)
{
$dbFields = [];
// if custom fields are specified, only select these
if (is_array($this->customFields)) {
foreach ($this->customFields as $fieldName) {
// @todo Possible security risk by making methods accessible - implement field-level security
if (($obj->hasField($fieldName) && !is_object($obj->getField($fieldName)))
|| $obj->hasMethod("get{$fieldName}")
) {
$dbFields[$fieldName] = $fieldName;
}
}
} else {
// by default, all database fields are selected
$dbFields = DataObject::getSchema()->fieldSpecs(get_class($obj));
// $dbFields = $obj->inheritedDatabaseFields();
}
if (is_array($this->customAddFields)) {
foreach ($this->customAddFields as $fieldName) {
// @todo Possible security risk by making methods accessible - implement field-level security
if ($obj->hasField($fieldName) || $obj->hasMethod("get{$fieldName}")) {
$dbFields[$fieldName] = $fieldName;
}
}
}
// add default required fields
$dbFields = array_merge($dbFields, ['ID' => 'Int']);
if (is_array($this->removeFields)) {
$dbFields = array_diff_key(
$dbFields ?? [],
array_combine($this->removeFields ?? [], $this->removeFields ?? [])
);
}
return $dbFields;
}
/**
* Return an array of the extensions that this data formatter supports
*/
abstract public function supportedExtensions();
abstract public function supportedMimeTypes();
/**
* Convert a single data object to this format. Return a string.
*
* @param DataObjectInterface $do
* @return mixed
*/
abstract public function convertDataObject(DataObjectInterface $do);
/**
* Convert a data object set to this format. Return a string.
*
* @param SS_List $set
* @return string
*/
abstract public function convertDataObjectSet(SS_List $set);
/**
* Convert an array to this format. Return a string.
*
* @param $array
* @return string
*/
abstract public function convertArray($array);
/**
* @param string $strData HTTP Payload as string
*/
public function convertStringToArray($strData)
{
user_error('DataFormatter::convertStringToArray not implemented on subclass', E_USER_ERROR);
}
/**
* Convert an array of aliased field names to their Dataobject field name
*
* @param string $className
* @param string[] $fields
* @return string[]
*/
public function getRealFields($className, $fields)
{
$apiMapping = $this->getApiMapping($className);
if (is_array($apiMapping) && is_array($fields)) {
$mappedFields = [];
foreach ($fields as $field) {
$mappedFields[] = $this->getMappedKey($apiMapping, $field);
}
return $mappedFields;
}
return $fields;
}
/**
* Get the DataObject field name from its alias
*
* @param string $className
* @param string $field
* @return string
*/
public function getRealFieldName($className, $field)
{
$apiMapping = $this->getApiMapping($className);
return $this->getMappedKey($apiMapping, $field);
}
/**
* Get a DataObject Field's Alias
* defaults to the fieldname
*
* @param string $className
* @param string $field
* @return string
*/
public function getFieldAlias($className, $field)
{
$apiMapping = $this->getApiMapping($className);
$apiMapping = array_flip($apiMapping ?? []);
return $this->getMappedKey($apiMapping, $field);
}
/**
* Get the 'api_field_mapping' config value for a class
* or return an empty array
*
* @param string $className
* @return string[]|array
*/
protected function getApiMapping($className)
{
$apiMapping = Config::inst()->get($className, 'api_field_mapping');
if ($apiMapping && is_array($apiMapping)) {
return $apiMapping;
}
return [];
}
/**
* Helper function to get mapped field names
*
* @param array $map
* @param string $key
* @return string
*/
protected function getMappedKey($map, $key)
{
if (is_array($map)) {
if (array_key_exists($key, $map ?? [])) {
return $map[$key];
} else {
return $key;
}
}
return $key;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace SilverStripe\RestfulServer\DataFormatter;
/**
* Accepts form encoded strings and converts them
* to a valid PHP array via {@link parse_str()}.
*
* Example when using cURL on commandline:
* <code>
* curl -d "Name=This is a new record" http://host/api/v1/(DataObject)
* curl -X PUT -d "Name=This is an updated record" http://host/api/v1/(DataObject)/1
* </code>
*
* @todo Format response form encoded as well - currently uses XMLDataFormatter
*
* @author Cam Spiers <camspiers at gmail dot com>
*/
class FormEncodedDataFormatter extends XMLDataFormatter
{
public function supportedExtensions()
{
return array(
);
}
public function supportedMimeTypes()
{
return array(
'application/x-www-form-urlencoded'
);
}
public function convertStringToArray($strData)
{
$postArray = array();
parse_str($strData ?? '', $postArray);
return $postArray;
//TODO: It would be nice to implement this function in Convert.php
//return Convert::querystr2array($strData);
}
}

View File

@ -0,0 +1,215 @@
<?php
namespace SilverStripe\RestfulServer\DataFormatter;
use SilverStripe\RestfulServer\RestfulServer;
use SilverStripe\View\ArrayData;
use SilverStripe\Core\Convert;
use SilverStripe\RestfulServer\DataFormatter;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\Control\Director;
use SilverStripe\ORM\SS_List;
use SilverStripe\ORM\FieldType;
/**
* Formats a DataObject's member fields into a JSON string
*/
class JSONDataFormatter extends DataFormatter
{
/**
* @config
* @todo pass this from the API to the data formatter somehow
*/
private static $api_base = "api/v1/";
protected $outputContentType = 'application/json';
/**
* @return array
*/
public function supportedExtensions()
{
return array(
'json',
'js'
);
}
/**
* @return array
*/
public function supportedMimeTypes()
{
return array(
'application/json',
'text/x-json'
);
}
/**
* @param $array
* @return string
*/
public function convertArray($array)
{
return json_encode($array);
}
/**
* Generate a JSON representation of the given {@link DataObject}.
*
* @param DataObject $obj The object
* @param Array $fields If supplied, only fields in the list will be returned
* @param $relations Not used
* @return String JSON
*/
public function convertDataObject(DataObjectInterface $obj, $fields = null, $relations = null)
{
return json_encode($this->convertDataObjectToJSONObject($obj, $fields, $relations));
}
/**
* Internal function to do the conversion of a single data object. It builds an empty object and dynamically
* adds the properties it needs to it. If it's done as a nested array, json_encode or equivalent won't use
* JSON object notation { ... }.
* @param DataObjectInterface $obj
* @param $fields
* @param $relations
* @return EmptyJSONObject
*/
public function convertDataObjectToJSONObject(DataObjectInterface $obj, $fields = null, $relations = null)
{
$className = get_class($obj);
$id = $obj->ID;
$serobj = ArrayData::array_to_object();
foreach ($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
// Field filtering
if ($fields && !in_array($fieldName, $fields ?? [])) {
continue;
}
$fieldValue = self::cast($obj->obj($fieldName));
$mappedFieldName = $this->getFieldAlias($className, $fieldName);
$serobj->$mappedFieldName = $fieldValue;
}
if ($this->relationDepth > 0) {
foreach ($obj->hasOne() as $relName => $relClass) {
if (!$relClass::config()->get('api_access')) {
continue;
}
// Field filtering
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
if ($obj->$relName() && (!$obj->$relName()->exists() || !$obj->$relName()->canView())) {
continue;
}
$fieldName = $relName . 'ID';
$rel = $this->config()->api_base;
$rel .= $obj->$fieldName
? $this->sanitiseClassName($relClass) . '/' . $obj->$fieldName
: $this->sanitiseClassName($className) . "/$id/$relName";
$href = Director::absoluteURL($rel);
$serobj->$relName = ArrayData::array_to_object(array(
"className" => $relClass,
"href" => "$href.json",
"id" => self::cast($obj->obj($fieldName))
));
}
foreach ($obj->hasMany() + $obj->manyMany() as $relName => $relClass) {
$relClass = RestfulServer::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (!$relClass::config()->get('api_access')) {
continue;
}
// Field filtering
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
$innerParts = array();
$items = $obj->$relName();
foreach ($items as $item) {
if (!$item->canView()) {
continue;
}
$rel = $this->config()->api_base . $this->sanitiseClassName($relClass) . "/$item->ID";
$href = Director::absoluteURL($rel);
$innerParts[] = ArrayData::array_to_object(array(
"className" => $relClass,
"href" => "$href.json",
"id" => $item->ID
));
}
$serobj->$relName = $innerParts;
}
}
return $serobj;
}
/**
* Generate a JSON representation of the given {@link SS_List}.
*
* @param SS_List $set
* @return String XML
*/
public function convertDataObjectSet(SS_List $set, $fields = null)
{
$items = array();
foreach ($set as $do) {
if (!$do->canView()) {
continue;
}
$items[] = $this->convertDataObjectToJSONObject($do, $fields);
}
$serobj = ArrayData::array_to_object(array(
"totalSize" => (is_numeric($this->totalSize)) ? $this->totalSize : null,
"items" => $items
));
return json_encode($serobj);
}
/**
* @param string $strData
* @return array|bool|void
*/
public function convertStringToArray($strData)
{
return json_decode($strData ?? '', true);
}
public static function cast(FieldType\DBField $dbfield)
{
switch (true) {
case $dbfield instanceof FieldType\DBInt:
return (int)$dbfield->RAW();
case $dbfield instanceof FieldType\DBFloat:
return (float)$dbfield->RAW();
case $dbfield instanceof FieldType\DBBoolean:
return (bool)$dbfield->RAW();
case is_null($dbfield->RAW()):
return null;
}
return $dbfield->RAW();
}
}

View File

@ -0,0 +1,341 @@
<?php
namespace SilverStripe\RestfulServer\DataFormatter;
use SimpleXMLElement;
use SilverStripe\Control\Controller;
use SilverStripe\Core\Convert;
use SilverStripe\Dev\Debug;
use SilverStripe\RestfulServer\DataFormatter;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\Control\Director;
use SilverStripe\ORM\SS_List;
use SilverStripe\RestfulServer\RestfulServer;
use InvalidArgumentException;
/**
* Formats a DataObject's member fields into an XML string
*/
class XMLDataFormatter extends DataFormatter
{
/**
* @config
* @todo pass this from the API to the data formatter somehow
*/
private static $api_base = "api/v1/";
protected $outputContentType = 'text/xml';
/**
* @return array
*/
public function supportedExtensions()
{
return array(
'xml'
);
}
/**
* @return array
*/
public function supportedMimeTypes()
{
return array(
'text/xml',
'application/xml',
);
}
/**
* @param $array
* @return string
* @throws \Exception
*/
public function convertArray($array)
{
$response = Controller::curr()->getResponse();
if ($response) {
$response->addHeader("Content-Type", "text/xml");
}
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n
<response>{$this->convertArrayWithoutHeader($array)}</response>";
}
/**
* @param $array
* @return string
* @throws \Exception
*/
public function convertArrayWithoutHeader($array)
{
$xml = '';
foreach ($array as $fieldName => $fieldValue) {
if (is_array($fieldValue)) {
if (is_numeric($fieldName)) {
$fieldName = 'Item';
}
$xml .= "<{$fieldName}>\n";
$xml .= $this->convertArrayWithoutHeader($fieldValue);
$xml .= "</{$fieldName}>\n";
} else {
$xml .= "<$fieldName>$fieldValue</$fieldName>\n";
}
}
return $xml;
}
/**
* Generate an XML representation of the given {@link DataObject}.
*
* @param DataObject $obj
* @param $includeHeader Include <?xml ...?> header (Default: true)
* @return String XML
*/
public function convertDataObject(DataObjectInterface $obj, $fields = null)
{
$response = Controller::curr()->getResponse();
if ($response) {
$response->addHeader("Content-Type", "text/xml");
}
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" . $this->convertDataObjectWithoutHeader($obj, $fields);
}
/**
* @param DataObject $obj
* @param null $fields
* @param null $relations
* @return string
*/
public function convertDataObjectWithoutHeader(DataObject $obj, $fields = null, $relations = null)
{
$className = $this->sanitiseClassName(get_class($obj));
$id = $obj->ID;
$objHref = Director::absoluteURL($this->config()->api_base . "$className/$obj->ID");
$xml = "<$className href=\"$objHref.xml\">\n";
foreach ($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
// Field filtering
if ($fields && !in_array($fieldName, $fields ?? [])) {
continue;
}
$fieldValue = $obj->obj($fieldName)->forTemplate();
if (!mb_check_encoding($fieldValue, 'utf-8')) {
$fieldValue = "(data is badly encoded)";
}
if (is_object($fieldValue) && is_subclass_of($fieldValue, 'Object') && $fieldValue->hasMethod('toXML')) {
$xml .= $fieldValue->toXML();
} else {
if ('HTMLText' == $fieldType) {
// Escape HTML values using CDATA
$fieldValue = sprintf('<![CDATA[%s]]>', str_replace(']]>', ']]]]><![CDATA[>', $fieldValue ?? ''));
} else {
$fieldValue = Convert::raw2xml($fieldValue);
}
$mappedFieldName = $this->getFieldAlias(get_class($obj), $fieldName);
$xml .= "<$mappedFieldName>$fieldValue</$mappedFieldName>\n";
}
}
if ($this->relationDepth > 0) {
foreach ($obj->hasOne() as $relName => $relClass) {
if (!singleton($relClass)::config()->get('api_access')) {
continue;
}
// Field filtering
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
$fieldName = $relName . 'ID';
if ($obj->$fieldName) {
$href = Director::absoluteURL($this->config()->api_base . "$relClass/" . $obj->$fieldName);
} else {
$href = Director::absoluteURL($this->config()->api_base . "$className/$id/$relName");
}
$xml .= "<$relName linktype=\"has_one\" href=\"$href.xml\" id=\"" . $obj->$fieldName
. "\"></$relName>\n";
}
foreach ($obj->hasMany() as $relName => $relClass) {
//remove dot notation from relation names
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (!singleton($relClass)::config()->get('api_access')) {
continue;
}
// backslashes in FQCNs kills both URIs and XML
$relClass = $this->sanitiseClassName($relClass);
// Field filtering
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
$xml .= "<$relName linktype=\"has_many\" href=\"$objHref/$relName.xml\">\n";
$items = $obj->$relName();
if ($items) {
foreach ($items as $item) {
$href = Director::absoluteURL($this->config()->api_base . "$relClass/$item->ID");
$xml .= "<$relClass href=\"$href.xml\" id=\"{$item->ID}\"></$relClass>\n";
}
}
$xml .= "</$relName>\n";
}
foreach ($obj->manyMany() as $relName => $relClass) {
$relClass = RestfulServer::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (!singleton($relClass)::config()->get('api_access')) {
continue;
}
// backslashes in FQCNs kills both URIs and XML
$relClass = $this->sanitiseClassName($relClass);
// Field filtering
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
$xml .= "<$relName linktype=\"many_many\" href=\"$objHref/$relName.xml\">\n";
$items = $obj->$relName();
if ($items) {
foreach ($items as $item) {
$href = Director::absoluteURL($this->config()->api_base . "$relClass/$item->ID");
$xml .= "<$relClass href=\"$href.xml\" id=\"{$item->ID}\"></$relClass>\n";
}
}
$xml .= "</$relName>\n";
}
}
$xml .= "</$className>";
return $xml;
}
/**
* Generate an XML representation of the given {@link SS_List}.
*
* @param SS_List $set
* @return String XML
*/
public function convertDataObjectSet(SS_List $set, $fields = null)
{
Controller::curr()->getResponse()->addHeader("Content-Type", "text/xml");
$className = $this->sanitiseClassName(get_class($set));
$xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
$xml .= (is_numeric($this->totalSize)) ? "<$className totalSize=\"{$this->totalSize}\">\n" : "<$className>\n";
foreach ($set as $item) {
$xml .= $this->convertDataObjectWithoutHeader($item, $fields);
}
$xml .= "</$className>";
return $xml;
}
/**
* @param string $strData
* @return array|void
* @throws \Exception
*/
public function convertStringToArray($strData)
{
return self::xml2array($strData);
}
/**
* This was copied from Convert::xml2array() which is deprecated/removed
*
* Converts an XML string to a PHP array
* See http://phpsecurity.readthedocs.org/en/latest/Injection-Attacks.html#xml-external-entity-injection
*
* @uses recursiveXMLToArray()
* @param string $val
* @param boolean $disableDoctypes Disables the use of DOCTYPE, and will trigger an error if encountered.
* false by default.
* @param boolean $disableExternals Does nothing because xml entities are removed
* @return array
* @throws Exception
*/
private static function xml2array($val, $disableDoctypes = false, $disableExternals = false)
{
// Check doctype
if ($disableDoctypes && strpos($val ?? '', '<!DOCTYPE') !== false) {
throw new InvalidArgumentException('XML Doctype parsing disabled');
}
// CVE-2021-41559 Ensure entities are removed due to their inherent security risk via
// XXE attacks and quadratic blowup attacks, and also lack of consistent support
$val = preg_replace('/(?s)<!ENTITY.*?>/', '', $val ?? '');
// If there's still an <!ENTITY> present, then it would be the result of a maliciously
// crafted XML document e.g. <!ENTITY><!<!ENTITY>ENTITY ext SYSTEM "http://evil.com">
if (strpos($val ?? '', '<!ENTITY') !== false) {
throw new InvalidArgumentException('Malicious XML entity detected');
}
// This will throw an exception if the XML contains references to any internal entities
// that were defined in an <!ENTITY /> before it was removed
$xml = new SimpleXMLElement($val ?? '');
return self::recursiveXMLToArray($xml);
}
/**
* @param SimpleXMLElement $xml
*
* @return mixed
*/
private static function recursiveXMLToArray($xml)
{
$x = null;
if ($xml instanceof SimpleXMLElement) {
$attributes = $xml->attributes();
foreach ($attributes as $k => $v) {
if ($v) {
$a[$k] = (string) $v;
}
}
$x = $xml;
$xml = get_object_vars($xml);
}
if (is_array($xml)) {
if (count($xml ?? []) === 0) {
return (string)$x;
} // for CDATA
$r = [];
foreach ($xml as $key => $value) {
$r[$key] = self::recursiveXMLToArray($value);
}
// Attributes
if (isset($a)) {
$r['@'] = $a;
}
return $r;
}
return (string) $xml;
}
}

919
src/RestfulServer.php Normal file
View File

@ -0,0 +1,919 @@
<?php
namespace SilverStripe\RestfulServer;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\SS_List;
use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
/**
* Generic RESTful server, which handles webservice access to arbitrary DataObjects.
* Relies on serialization/deserialization into different formats provided
* by the DataFormatter APIs in core.
*
* @todo Implement PUT/POST/DELETE for relations
* @todo Access-Control for relations (you might be allowed to view Members and Groups,
* but not their relation with each other)
* @todo Make SearchContext specification customizeable for each class
* @todo Allow for range-searches (e.g. on Created column)
* @todo Filter relation listings by $api_access and canView() permissions
* @todo Exclude relations when "fields" are specified through URL (they should be explicitly
* requested in this case)
* @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in
* SiteTree/Versioned/Hierarchy
* @todo URL parameter namespacing for search-fields, limit, fields, add_fields
* (might all be valid dataobject properties)
* e.g. you wouldn't be able to search for a "limit" property on your subclass as
* its overlayed with the search logic
* @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
* @todo Access to extendable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
* @todo Respect $api_access array notation in search contexts
*/
class RestfulServer extends Controller
{
/**
* @config
* @var array
*/
private static $url_handlers = array(
'$ClassName!/$ID/$Relation' => 'handleAction',
'' => 'notFound'
);
/**
* @config
* @var string root of the api route, MUST have a trailing slash
*/
private static $api_base = "api/v1/";
/**
* @config
* @var string Class name for an authenticator to use on API access
*/
private static $authenticator = BasicRestfulAuthenticator::class;
/**
* If no extension is given in the request, resolve to this extension
* (and subsequently the {@link self::$default_mimetype}.
*
* @config
* @var string
*/
private static $default_extension = "xml";
/**
* Custom endpoints that map to a specific class.
* This is done to make the API have fixed endpoints,
* instead of using fully namespaced classnames, as the module does by default
* The fully namespaced classnames can also still be used though
* Example:
* ['mydataobject' => MyDataObject::class]
*
* @config array
*/
private static $endpoint_aliases = [];
/**
* Whether or not to send an additional "Location" header for POST requests
* to satisfy HTTP 1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
*
* Note: With this enabled (the default), no POST request for resource creation
* will return an HTTP 201. Because of the addition of the "Location" header,
* all responses become a straight HTTP 200.
*
* @config
* @var boolean
*/
private static $location_header_on_create = true;
/**
* If no extension is given, resolve the request to this mimetype.
*
* @var string
*/
protected static $default_mimetype = "text/xml";
/**
* @uses authenticate()
* @var Member
*/
protected $member;
private static $allowed_actions = array(
'index',
'notFound'
);
public function init()
{
/* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
* to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
* TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
*/
if (class_exists(SiteTree::class)) {
singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
}
parent::init();
}
/**
* Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
* kills both requests (i.e. URIs) and XML (invalid character in a tag name)
* So we'll replace them with a hyphen (-), as it's also unambiguious
* in both cases (invalid in a php class name, and safe in an xml tag name)
*
* @param string $classname
* @return string 'escaped' class name
*/
protected function sanitiseClassName($className)
{
return str_replace('\\', '-', $className ?? '');
}
/**
* Convert hyphen escaped class names back into fully qualified
* PHP safe variant.
*
* @param string $classname
* @return string syntactically valid classname
*/
protected function unsanitiseClassName($className)
{
return str_replace('-', '\\', $className ?? '');
}
/**
* Parse many many relation class (works with through array syntax)
*
* @param string|array $class
* @return string|array
*/
public static function parseRelationClass($class)
{
// detect many many through syntax
if (is_array($class)
&& array_key_exists('through', $class ?? [])
&& array_key_exists('to', $class ?? [])
) {
$toRelation = $class['to'];
$hasOne = Config::inst()->get($class['through'], 'has_one');
if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne ?? [])) {
return $class;
}
return $hasOne[$toRelation];
}
return $class;
}
/**
* This handler acts as the switchboard for the controller.
* Since no $Action url-param is set, all requests are sent here.
*/
public function index(HTTPRequest $request)
{
$className = $this->resolveClassName($request);
$id = $request->param('ID') ?: null;
$relation = $request->param('Relation') ?: null;
// Check input formats
if (!class_exists($className ?? '')) {
return $this->notFound();
}
if ($id && !is_numeric($id)) {
return $this->notFound();
}
if ($relation
&& !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation ?? '')
) {
return $this->notFound();
}
// if api access is disabled, don't proceed
$apiAccess = Config::inst()->get($className, 'api_access');
if (!$apiAccess) {
return $this->permissionFailure();
}
// authenticate through HTTP BasicAuth
$this->member = $this->authenticate();
try {
// handle different HTTP verbs
if ($this->request->isGET() || $this->request->isHEAD()) {
return $this->getHandler($className, $id, $relation);
}
if ($this->request->isPOST()) {
return $this->postHandler($className, $id, $relation);
}
if ($this->request->isPUT()) {
return $this->putHandler($className, $id, $relation);
}
if ($this->request->isDELETE()) {
return $this->deleteHandler($className, $id, $relation);
}
} catch (\Exception $e) {
return $this->exceptionThrown($this->getRequestDataFormatter($className), $e);
}
// if no HTTP verb matches, return error
return $this->methodNotAllowed();
}
/**
* Handler for object read.
*
* The data object will be returned in the following format:
*
* <ClassName>
* <FieldName>Value</FieldName>
* ...
* <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
* ...
* <HasManyRelName>
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* </HasManyRelName>
* ...
* <ManyManyRelName>
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
* </ManyManyRelName>
* </ClassName>
*
* Access is controlled by two variables:
*
* - static $api_access must be set. This enables the API on a class by class basis
* - $obj->canView() must return true. This lets you implement record-level security
*
* @todo Access checking
*
* @param string $className
* @param int $id
* @param string $relation
* @return string The serialized representation of the requested object(s) - usually XML or JSON.
*/
protected function getHandler($className, $id, $relationName)
{
$sort = ['ID' => 'ASC'];
if ($sortQuery = $this->request->getVar('sort')) {
/** @var DataObject $singleton */
$singleton = singleton($className);
// Only apply a sort filter if it is a valid field on the DataObject
if ($singleton && $singleton->hasDatabaseField($sortQuery)) {
$sort = [
$sortQuery => $this->request->getVar('dir') === 'DESC' ? 'DESC' : 'ASC',
];
}
}
$limit = [
'start' => (int) $this->request->getVar('start'),
'limit' => (int) $this->request->getVar('limit'),
];
if ($limit['limit'] === 0) {
$limit = null;
}
$params = $this->request->getVars();
$responseFormatter = $this->getResponseDataFormatter($className);
if (!$responseFormatter) {
return $this->unsupportedMediaType();
}
// $obj can be either a DataObject or a SS_List,
// depending on the request
if ($id) {
// Format: /api/v1/<MyClass>/<ID>
$obj = $this->getObjectQuery($className, $id, $params)->First();
if (!$obj) {
return $this->notFound();
}
if (!$obj->canView($this->getMember())) {
return $this->permissionFailure();
}
// Format: /api/v1/<MyClass>/<ID>/<Relation>
if ($relationName) {
$obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
if (!$obj) {
return $this->notFound();
}
// TODO Avoid creating data formatter again for relation class (see above)
$responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
}
} else {
// Format: /api/v1/<MyClass>
$obj = $this->getObjectsQuery($className, $params, $sort, $limit);
}
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$rawFields = $this->request->getVar('fields');
$realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields ?? ''));
$fields = $rawFields ? $realFields : null;
if ($obj instanceof SS_List) {
$objs = ArrayList::create($obj->toArray());
foreach ($objs as $obj) {
if (!$obj->canView($this->getMember())) {
$objs->remove($obj);
}
}
$responseFormatter->setTotalSize($objs->count());
$this->extend('updateRestfulGetHandler', $objs, $responseFormatter);
return $responseFormatter->convertDataObjectSet($objs, $fields);
}
if (!$obj) {
$responseFormatter->setTotalSize(0);
return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
}
$this->extend('updateRestfulGetHandler', $obj, $responseFormatter);
return $responseFormatter->convertDataObject($obj, $fields);
}
/**
* Uses the default {@link SearchContext} specified through
* {@link DataObject::getDefaultSearchContext()} to augument
* an existing query object (mostly a component query from {@link DataObject})
* with search clauses.
*
* @todo Allow specifying of different searchcontext getters on model-by-model basis
*
* @param string $className
* @param array $params
* @return SS_List
*/
protected function getSearchQuery(
$className,
$params = null,
$sort = null,
$limit = null,
$existingQuery = null
) {
if (singleton($className)->hasMethod('getRestfulSearchContext')) {
$searchContext = singleton($className)->{'getRestfulSearchContext'}();
} else {
$searchContext = singleton($className)->getDefaultSearchContext();
}
return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
}
/**
* Returns a dataformatter instance based on the request
* extension or mimetype. Falls back to {@link self::$default_extension}.
*
* @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
* @param string Classname of a DataObject
* @return DataFormatter
*/
protected function getDataFormatter($includeAcceptHeader = false, $className = null)
{
$extension = $this->request->getExtension();
$contentTypeWithEncoding = $this->request->getHeader('Content-Type');
preg_match('/([^;]*)/', $contentTypeWithEncoding ?? '', $contentTypeMatches);
$contentType = $contentTypeMatches[0];
$accept = $this->request->getHeader('Accept');
$mimetypes = $this->request->getAcceptMimetypes();
if (!$className) {
$className = $this->resolveClassName($this->request);
}
// get formatter
if (!empty($extension)) {
$formatter = DataFormatter::for_extension($extension);
} elseif ($includeAcceptHeader && !empty($accept) && strpos($accept ?? '', '*/*') === false) {
$formatter = DataFormatter::for_mimetypes($mimetypes);
if (!$formatter) {
$formatter = DataFormatter::for_extension($this->config()->default_extension);
}
} elseif (!empty($contentType)) {
$formatter = DataFormatter::for_mimetype($contentType);
} else {
$formatter = DataFormatter::for_extension($this->config()->default_extension);
}
if (!$formatter) {
return false;
}
// set custom fields
if ($customAddFields = $this->request->getVar('add_fields')) {
$customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields ?? ''));
$formatter->setCustomAddFields($customAddFields);
}
if ($customFields = $this->request->getVar('fields')) {
$customFields = $formatter->getRealFields($className, explode(',', $customFields ?? ''));
$formatter->setCustomFields($customFields);
}
$formatter->setCustomRelations($this->getAllowedRelations($className));
$apiAccess = Config::inst()->get($className, 'api_access');
if (is_array($apiAccess)) {
$formatter->setCustomAddFields(
array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
);
if ($formatter->getCustomFields()) {
$formatter->setCustomFields(
array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
);
} else {
$formatter->setCustomFields((array)$apiAccess['view']);
}
if ($formatter->getCustomRelations()) {
$formatter->setCustomRelations(
array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
);
} else {
$formatter->setCustomRelations((array)$apiAccess['view']);
}
}
// set relation depth
$relationDepth = $this->request->getVar('relationdepth');
if (is_numeric($relationDepth)) {
$formatter->relationDepth = (int)$relationDepth;
}
return $formatter;
}
/**
* @param string Classname of a DataObject
* @return DataFormatter
*/
protected function getRequestDataFormatter($className = null)
{
return $this->getDataFormatter(false, $className);
}
/**
* @param string Classname of a DataObject
* @return DataFormatter
*/
protected function getResponseDataFormatter($className = null)
{
return $this->getDataFormatter(true, $className);
}
/**
* Handler for object delete
*/
protected function deleteHandler($className, $id)
{
$obj = DataObject::get_by_id($className, $id);
if (!$obj) {
return $this->notFound();
}
if (!$obj->canDelete($this->getMember())) {
return $this->permissionFailure();
}
$obj->delete();
$this->getResponse()->setStatusCode(204); // No Content
return true;
}
/**
* Handler for object write
*/
protected function putHandler($className, $id)
{
$obj = DataObject::get_by_id($className, $id);
if (!$obj) {
return $this->notFound();
}
if (!$obj->canEdit($this->getMember())) {
return $this->permissionFailure();
}
$reqFormatter = $this->getRequestDataFormatter($className);
if (!$reqFormatter) {
return $this->unsupportedMediaType();
}
$responseFormatter = $this->getResponseDataFormatter($className);
if (!$responseFormatter) {
return $this->unsupportedMediaType();
}
try {
/** @var DataObject|string */
$obj = $this->updateDataObject($obj, $reqFormatter);
} catch (ValidationException $e) {
return $this->validationFailure($responseFormatter, $e->getResult());
}
if (is_string($obj)) {
return $obj;
}
$this->getResponse()->setStatusCode(202); // Accepted
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
// Append the default extension for the output format to the Location header
// or else we'll use the default (XML)
$types = $responseFormatter->supportedExtensions();
$type = '';
if (count($types ?? [])) {
$type = ".{$types[0]}";
}
$urlSafeClassName = $this->sanitiseClassName(get_class($obj));
$apiBase = $this->config()->api_base;
$objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
$this->getResponse()->addHeader('Location', $objHref);
return $responseFormatter->convertDataObject($obj);
}
/**
* Handler for object append / method call.
*
* @todo Posting to an existing URL (without a relation)
* current resolves in creatig a new element,
* rather than a "Conflict" message.
*/
protected function postHandler($className, $id, $relation)
{
if ($id) {
if (!$relation) {
$this->response->setStatusCode(409);
return 'Conflict';
}
$obj = DataObject::get_by_id($className, $id);
if (!$obj) {
return $this->notFound();
}
$reqFormatter = $this->getRequestDataFormatter($className);
if (!$reqFormatter) {
return $this->unsupportedMediaType();
}
$relation = $reqFormatter->getRealFieldName($className, $relation);
if (!$obj->hasMethod($relation)) {
return $this->notFound();
}
if (!Config::inst()->get($className, 'allowed_actions') ||
!in_array($relation, Config::inst()->get($className, 'allowed_actions') ?? [])) {
return $this->permissionFailure();
}
$obj->$relation();
$this->getResponse()->setStatusCode(204); // No Content
return true;
}
if (!singleton($className)->canCreate($this->getMember())) {
return $this->permissionFailure();
}
$obj = Injector::inst()->create($className);
$reqFormatter = $this->getRequestDataFormatter($className);
if (!$reqFormatter) {
return $this->unsupportedMediaType();
}
$responseFormatter = $this->getResponseDataFormatter($className);
try {
/** @var DataObject|string $obj */
$obj = $this->updateDataObject($obj, $reqFormatter);
} catch (ValidationException $e) {
return $this->validationFailure($responseFormatter, $e->getResult());
}
if (is_string($obj)) {
return $obj;
}
$this->getResponse()->setStatusCode(201); // Created
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
// Append the default extension for the output format to the Location header
// or else we'll use the default (XML)
$types = $responseFormatter->supportedExtensions();
$type = '';
if (count($types ?? [])) {
$type = ".{$types[0]}";
}
// Deviate slightly from the spec: Helps datamodel API access restrict
// to consulting just canCreate(), not canView() as a result of the additional
// "Location" header.
if ($this->config()->get('location_header_on_create')) {
$urlSafeClassName = $this->sanitiseClassName(get_class($obj));
$apiBase = $this->config()->api_base;
$objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
$this->getResponse()->addHeader('Location', $objHref);
}
return $responseFormatter->convertDataObject($obj);
}
/**
* Converts either the given HTTP Body into an array
* (based on the DataFormatter instance), or returns
* the POST variables.
* Automatically filters out certain critical fields
* that shouldn't be set by the client (e.g. ID).
*
* @param DataObject $obj
* @param DataFormatter $formatter
* @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
*/
protected function updateDataObject($obj, $formatter)
{
// if neither an http body nor POST data is present, return error
$body = $this->request->getBody();
if (!$body && !$this->request->postVars()) {
$this->getResponse()->setStatusCode(204); // No Content
return 'No Content';
}
if (!empty($body)) {
$rawdata = $formatter->convertStringToArray($body);
} else {
// assume application/x-www-form-urlencoded which is automatically parsed by PHP
$rawdata = $this->request->postVars();
}
$className = $obj->ClassName;
// update any aliased field names
$data = [];
foreach ($rawdata as $key => $value) {
$newkey = $formatter->getRealFieldName($className, $key);
$data[$newkey] = $value;
}
// @todo Disallow editing of certain keys in database
$data = array_diff_key($data ?? [], ['ID', 'Created']);
$apiAccess = singleton($className)->config()->api_access;
if (is_array($apiAccess) && isset($apiAccess['edit'])) {
$data = array_intersect_key($data ?? [], array_combine($apiAccess['edit'] ?? [], $apiAccess['edit'] ?? []));
}
$obj->update($data);
$obj->write();
return $obj;
}
/**
* Gets a single DataObject by ID,
* through a request like /api/v1/<MyClass>/<MyID>
*
* @param string $className
* @param int $id
* @param array $params
* @return DataList
*/
protected function getObjectQuery($className, $id, $params)
{
return DataList::create($className)->byIDs([$id]);
}
/**
* @param DataObject $obj
* @param array $params
* @param int|array $sort
* @param int|array $limit
* @return SQLQuery
*/
protected function getObjectsQuery($className, $params, $sort, $limit)
{
return $this->getSearchQuery($className, $params, $sort, $limit);
}
/**
* @param DataObject $obj
* @param array $params
* @param int|array $sort
* @param int|array $limit
* @param string $relationName
* @return SQLQuery|boolean
*/
protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
{
// The relation method will return a DataList, that getSearchQuery subsequently manipulates
if ($obj->hasMethod($relationName)) {
// $this->HasOneName() will return a dataobject or null, neither
// of which helps us get the classname in a consistent fashion.
// So we must use a way that is reliable.
if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
$joinField = $relationName . 'ID';
// Again `byID` will return the wrong type for our purposes. So use `byIDs`
$list = DataList::create($relationClass)->byIDs([$obj->$joinField]);
} else {
$list = $obj->$relationName();
}
$apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
if (!$apiAccess) {
return false;
}
return $this->getSearchQuery($list->dataClass(), $params, $sort, $limit, $list);
}
}
/**
* @return string
*/
protected function permissionFailure()
{
// return a 401
$this->getResponse()->setStatusCode(401);
$this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
$this->getResponse()->addHeader('Content-Type', 'text/plain');
$response = "You don't have access to this item through the API.";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
* @return string
*/
protected function notFound()
{
// return a 404
$this->getResponse()->setStatusCode(404);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
$response = "That object wasn't found";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
* @return string
*/
protected function methodNotAllowed()
{
$this->getResponse()->setStatusCode(405);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
$response = "Method Not Allowed";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
* @return string
*/
protected function unsupportedMediaType()
{
$this->response->setStatusCode(415); // Unsupported Media Type
$this->getResponse()->addHeader('Content-Type', 'text/plain');
$response = "Unsupported Media Type";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
* @param ValidationResult $result
* @return mixed
*/
protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
{
$this->getResponse()->setStatusCode(400);
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$response = [
'type' => ValidationException::class,
'messages' => $result->getMessages(),
];
$this->extend(__FUNCTION__, $response, $result);
return $responseFormatter->convertArray($response);
}
/**
* @param DataFormatter $responseFormatter
* @param \Exception $e
* @return string
*/
protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
{
$this->getResponse()->setStatusCode(500);
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$response = [
'type' => get_class($e),
'message' => $e->getMessage(),
];
$this->extend(__FUNCTION__, $response, $e);
return $responseFormatter->convertArray($response);
}
/**
* A function to authenticate a user
*
* @return Member|false the logged in member
*/
protected function authenticate()
{
$authClass = $this->config()->authenticator;
$member = $authClass::authenticate();
Security::setCurrentUser($member);
return $member;
}
/**
* Return only relations which have $api_access enabled.
* @todo Respect field level permissions once they are available in core
*
* @param string $class
* @param Member $member
* @return array
*/
protected function getAllowedRelations($class, $member = null)
{
$allowedRelations = [];
$obj = singleton($class);
$relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
if ($relations) {
foreach ($relations as $relName => $relClass) {
$relClass = static::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (Config::inst()->get($relClass, 'api_access')) {
$allowedRelations[] = $relName;
}
}
}
return $allowedRelations;
}
/**
* Get the current Member, if available
*
* @return Member|null
*/
protected function getMember()
{
return Security::getCurrentUser();
}
/**
* Checks if given param ClassName maps to an object in endpoint_aliases,
* else simply return the unsanitised version of ClassName
*
* @param HTTPRequest $request
* @return string
*/
protected function resolveClassName(HTTPRequest $request)
{
$className = $request->param('ClassName');
$aliases = self::config()->get('endpoint_aliases');
return empty($aliases[$className]) ? $this->unsanitiseClassName($className) : $aliases[$className];
}
}

32
src/RestfulServerItem.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace SilverStripe\RestfulServer;
use SilverStripe\ORM\SS_List;
/**
* Restful server handler for a single DataObject
*/
class RestfulServerItem
{
private static $url_handlers = array(
'$Relation' => 'handleRelation',
);
public function __construct($item)
{
$this->item = $item;
}
public function handleRelation($request)
{
$funcName = $request('Relation');
$relation = $this->item->$funcName();
if ($relation instanceof SS_List) {
return new RestfulServerList($relation);
} else {
return new RestfulServerItem($relation);
}
}
}

23
src/RestfulServerList.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace SilverStripe\RestfulServer;
/**
* Restful server handler for a SS_List
*/
class RestfulServerList
{
private static $url_handlers = array(
'#ID' => 'handleItem',
);
public function __construct($list)
{
$this->list = $list;
}
public function handleItem($request)
{
return new RestfulServerItem($this->list->getById($request->param('ID')));
}
}

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="SilverStripe">
<description>CodeSniffer ruleset for SilverStripe coding conventions.</description>
<!-- exclude SCSS-generated CSS files -->
<exclude-pattern>*/css/*</exclude-pattern>
<exclude-pattern>css/*</exclude-pattern>
<!-- exclude thirdparty content -->
<exclude-pattern>thirdparty/*</exclude-pattern>
<rule ref="Generic.Files.LineEndings.InvalidEOLChar">
<severity>8</severity>
</rule>
<rule ref="Generic.Files.LineEndings">
<properties>
<property name="eolChar" value="\n" />
</properties>
</rule>
<rule ref="Generic.Files.LineLength.TooLong">
<severity>7</severity>
</rule>
<rule ref="Generic.Files.LineLength.MaxExceeded">
<severity>8</severity>
</rule>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120"/>
<property name="absoluteLineLimit" value="120"/>
</properties>
</rule>
</ruleset>

View File

@ -1,22 +0,0 @@
<?php
global $project;
$project = 'mysite';
global $database;
$database = '';
require_once('conf/ConfigureFromEnv.php');
global $databaseConfig;
$databaseConfig['memory'] = true;
$databaseConfig['path'] = dirname(dirname(__FILE__)) .'/assets/';
MySQLDatabase::set_connection_charset('utf8');
// Set the current theme. More themes can be downloaded from
// http://www.silverstripe.org/themes/
SSViewer::set_theme('simple');
// Enable nested URLs for this site (e.g. page/sub-page/)
if(class_exists('SiteTree')) SiteTree::enable_nested_urls();

View File

@ -1,37 +0,0 @@
<?php
ob_start();
define('SS_ENVIRONMENT_TYPE', 'dev');
/* Database connection */
$db = getenv('TESTDB');
switch($db) {
case "PGSQL";
define('SS_DATABASE_CLASS', 'PostgreSQLDatabase');
define('SS_DATABASE_USERNAME', 'postgres');
define('SS_DATABASE_PASSWORD', '');
break;
case "MYSQL":
define('SS_DATABASE_CLASS', 'MySQLDatabase');
define('SS_DATABASE_USERNAME', 'root');
define('SS_DATABASE_PASSWORD', '');
break;
default:
define('SS_DATABASE_CLASS', 'SQLitePDODatabase');
define('SS_DATABASE_USERNAME', 'root');
define('SS_DATABASE_PASSWORD', '');
}
echo SS_DATABASE_CLASS;
define('SS_DATABASE_SERVER', 'localhost');
define('SS_DATABASE_CHOOSE_NAME', true);
/* Configure a default username and password to access the CMS on all sites in this environment. */
define('SS_DEFAULT_ADMIN_USERNAME', 'username');
define('SS_DEFAULT_ADMIN_PASSWORD', 'password');
$_FILE_TO_URL_MAPPING[dirname(__FILE__)] = 'http://localhost';

View File

@ -1,23 +0,0 @@
### USAGE: before_script <base-folder>
BUILD_DIR=$1
# Fetch all dependencies
# TODO Replace with different composer.json variations
echo "Checking out installer@$CORE_RELEASE"
git clone --depth=100 --quiet --branch $CORE_RELEASE git://github.com/silverstripe/silverstripe-installer.git $BUILD_DIR
echo "Checking out framework@$CORE_RELEASE"
git clone --depth=100 --quiet --branch $CORE_RELEASE git://github.com/silverstripe/sapphire.git $BUILD_DIR/framework
echo "Checking out sqlite3"
git clone --depth=100 --quiet git://github.com/silverstripe-labs/silverstripe-sqlite3.git $BUILD_DIR/sqlite3
echo "Checking out postgresql"
git clone --depth=100 --quiet git://github.com/silverstripe/silverstripe-postgresql.git $BUILD_DIR/postgresql
# Copy setup files
cp ./tests/travis/_ss_environment.php $BUILD_DIR
cp ./tests/travis/_config.php $BUILD_DIR/mysite
# Copy actual project code into build directory (checked out by travis)
cp -r . $BUILD_DIR/restfulserver
cd $BUILD_DIR

View File

@ -0,0 +1,50 @@
<?php
namespace SilverStripe\RestfulServer\Tests;
use SilverStripe\RestfulServer\RestfulServer;
use SilverStripe\RestfulServer\Tests\Stubs\JSONDataFormatterTypeTestObject;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter;
/**
*
* @todo Test Relation getters
* @todo Test filter and limit through GET params
* @todo Test DELETE verb
*
*/
class JSONDataFormatterTest extends SapphireTest
{
protected static $fixture_file = 'JSONDataFormatterTest.yml';
protected static $extra_dataobjects = [
JSONDataFormatterTypeTestObject::class,
];
public function testJSONTypes()
{
$formatter = new JSONDataFormatter();
$parent = $this->objFromFixture(JSONDataFormatterTypeTestObject::class, 'parent');
$json = $formatter->convertDataObject($parent);
$this->assertMatchesRegularExpression('/"ID":\d+/', $json, 'PK casted to integer');
$this->assertMatchesRegularExpression(
'/"Created":"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/',
$json,
'Datetime casted to string'
);
$this->assertStringContainsString('"Name":"Parent"', $json, 'String casted to string');
$this->assertStringContainsString('"Active":true', $json, 'Boolean casted to boolean');
$this->assertStringContainsString('"Sort":17', $json, 'Integer casted to integer');
$this->assertStringContainsString('"Average":1.2345', $json, 'Float casted to float');
$this->assertStringContainsString('"ParentID":0', $json, 'Empty FK is 0');
$child3 = $this->objFromFixture(JSONDataFormatterTypeTestObject::class, 'child3');
$json = $formatter->convertDataObject($child3);
$this->assertStringContainsString('"Name":null', $json, 'Empty string is null');
$this->assertStringContainsString('"Active":false', $json, 'Empty boolean is false');
$this->assertStringContainsString('"Sort":0', $json, 'Empty integer is 0');
$this->assertStringContainsString('"Average":0', $json, 'Empty float is 0');
$this->assertMatchesRegularExpression('/"ParentID":\d+/', $json, 'FK casted to integer');
}
}

View File

@ -0,0 +1,20 @@
SilverStripe\RestfulServer\Tests\Stubs\JSONDataFormatterTypeTestObject:
parent:
Name: Parent
Active: true
Sort: 17
Average: 1.2345
child1:
Name: Child 1
Active: 1
Sort: 4
Average: 6.78
Parent: =>SilverStripe\RestfulServer\Tests\Stubs\JSONDataFormatterTypeTestObject.parent
child2:
Name: Child 2
Active: false
Sort: 9
Average: 1
Parent: =>SilverStripe\RestfulServer\Tests\Stubs\JSONDataFormatterTypeTestObject.parent
child3:
Parent: =>SilverStripe\RestfulServer\Tests\Stubs\JSONDataFormatterTypeTestObject.parent

View File

@ -1,601 +1,789 @@
<?php
namespace SilverStripe\RestfulServer\Tests;
use SilverStripe\RestfulServer\RestfulServer;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestComment;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestExceptionThrown;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestSecretThing;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthorRating;
use SilverStripe\Control\Director;
use SilverStripe\Core\Convert;
use SilverStripe\Control\Controller;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestValidationFailure;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
use SilverStripe\ORM\DataObject;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter;
use Page;
use SilverStripe\Core\Config\Config;
use SilverStripe\RestfulServer\DataFormatter\XMLDataFormatter;
/**
*
*
* @todo Test Relation getters
* @todo Test filter and limit through GET params
* @todo Test DELETE verb
*
*/
class RestfulServerTest extends SapphireTest {
static $fixture_file = 'RestfulServerTest.yml';
class RestfulServerTest extends SapphireTest
{
protected static $fixture_file = 'RestfulServerTest.yml';
protected $extraDataObjects = array(
'RestfulServerTest_Comment',
'RestfulServerTest_SecretThing',
'RestfulServerTest_Page',
'RestfulServerTest_Author',
'RestfulServerTest_AuthorRating',
);
protected $baseURI = 'http://www.fakesite.test';
public function testApiAccess() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$page1 = $this->objFromFixture('RestfulServerTest_Page', 'page1');
// normal GET should succeed with $api_access enabled
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
// even with logged in user a GET with $api_access disabled should fail
$url = "/api/v1/RestfulServerTest_Page/" . $page1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 401);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testApiAccessBoolean() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<ID>', $response->getBody());
$this->assertContains('<Name>', $response->getBody());
$this->assertContains('<Comment>', $response->getBody());
$this->assertContains('<Page', $response->getBody());
$this->assertContains('<Author', $response->getBody());
}
public function testAuthenticatedGET() {
$thing1 = $this->objFromFixture('RestfulServerTest_SecretThing', 'thing1');
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
protected static $extra_dataobjects = [
RestfulServerTestComment::class,
RestfulServerTestSecretThing::class,
RestfulServerTestPage::class,
RestfulServerTestAuthor::class,
RestfulServerTestAuthorRating::class,
RestfulServerTestValidationFailure::class,
RestfulServerTestExceptionThrown::class,
];
// @todo create additional mock object with authenticated VIEW permissions
$url = "/api/v1/RestfulServerTest_SecretThing/" . $thing1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 401);
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testAuthenticatedPUT() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$data = array('Comment' => 'created');
$response = Director::test($url, $data, null, 'PUT');
$this->assertEquals($response->getStatusCode(), 401); // Permission failure
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$response = Director::test($url, $data, null, 'PUT');
$this->assertEquals($response->getStatusCode(), 200); // Success
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testGETRelationshipsXML() {
$author1 = $this->objFromFixture('RestfulServerTest_Author', 'author1');
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating', 'rating1');
$rating2 = $this->objFromFixture('RestfulServerTest_AuthorRating', 'rating2');
// @todo should be set up by fixtures, doesn't work for some reason...
$author1->Ratings()->add($rating1);
$author1->Ratings()->add($rating2);
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
$responseArr = Convert::xml2array($response->getBody());
$ratingsArr = $responseArr['Ratings']['RestfulServerTest_AuthorRating'];
$this->assertEquals(count($ratingsArr), 2);
$ratingIDs = array(
(int)$ratingsArr[0]['@attributes']['id'],
(int)$ratingsArr[1]['@attributes']['id']
);
$this->assertContains($rating1->ID, $ratingIDs);
$this->assertContains($rating2->ID, $ratingIDs);
}
public function testGETManyManyRelationshipsXML() {
// author4 has related authors author2 and author3
$author2 = $this->objFromFixture('RestfulServerTest_Author', 'author2');
$author3 = $this->objFromFixture('RestfulServerTest_Author', 'author3');
$author4 = $this->objFromFixture('RestfulServerTest_Author', 'author4');
$url = "/api/v1/RestfulServerTest_Author/" . $author4->ID . '/RelatedAuthors';
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$arr = Convert::xml2array($response->getBody());
$authorsArr = $arr['RestfulServerTest_Author'];
$this->assertEquals(count($authorsArr), 2);
$ratingIDs = array(
(int)$authorsArr[0]['ID'],
(int)$authorsArr[1]['ID']
);
$this->assertContains($author2->ID, $ratingIDs);
$this->assertContains($author3->ID, $ratingIDs);
}
protected function urlSafeClassname($classname)
{
return str_replace('\\', '-', $classname ?? '');
}
public function testPUTWithFormEncoded() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$body = 'Name=Updated Comment&Comment=updated';
$headers = array(
'Content-Type' => 'application/x-www-form-urlencoded'
);
$response = Director::test($url, null, null, 'PUT', $body, $headers);
$this->assertEquals($response->getStatusCode(), 200); // Success
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals($responseArr['ID'], $comment1->ID);
$this->assertEquals($responseArr['Comment'], 'updated');
$this->assertEquals($responseArr['Name'], 'Updated Comment');
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPOSTWithFormEncoded() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$url = "/api/v1/RestfulServerTest_Comment";
$body = 'Name=New Comment&Comment=created';
$headers = array(
'Content-Type' => 'application/x-www-form-urlencoded'
);
$response = Director::test($url, null, null, 'POST', $body, $headers);
$this->assertEquals($response->getStatusCode(), 201); // Created
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertTrue($responseArr['ID'] > 0);
$this->assertNotEquals($responseArr['ID'], $comment1->ID);
$this->assertEquals($responseArr['Comment'], 'created');
$this->assertEquals($responseArr['Name'], 'New Comment');
$this->assertEquals(
$response->getHeader('Location'),
Controller::join_links(Director::absoluteBaseURL(), $url, $responseArr['ID'])
);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPUTwithJSON() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
// by mimetype
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body, array('Content-Type'=>'application/json'));
$this->assertEquals($response->getStatusCode(), 200); // Updated
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($obj->ID, $comment1->ID);
$this->assertEquals($obj->Comment, 'updated');
// by extension
$url = sprintf("/api/v1/RestfulServerTest_Comment/%d.json", $comment1->ID);
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals($response->getStatusCode(), 200); // Updated
$this->assertEquals(
$response->getHeader('Location'),
Controller::join_links(Director::absoluteBaseURL(), $url)
);
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($obj->ID, $comment1->ID);
$this->assertEquals($obj->Comment, 'updated');
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPUTwithXML() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
// by mimetype
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$body = '<RestfulServerTest_Comment><Comment>updated</Comment></RestfulServerTest_Comment>';
$response = Director::test($url, null, null, 'PUT', $body, array('Content-Type'=>'text/xml'));
$this->assertEquals($response->getStatusCode(), 200); // Updated
$obj = Convert::xml2array($response->getBody());
$this->assertEquals($obj['ID'], $comment1->ID);
$this->assertEquals($obj['Comment'], 'updated');
// by extension
$url = sprintf("/api/v1/RestfulServerTest_Comment/%d.xml", $comment1->ID);
$body = '<RestfulServerTest_Comment><Comment>updated</Comment></RestfulServerTest_Comment>';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals($response->getStatusCode(), 200); // Updated
$this->assertEquals(
$response->getHeader('Location'),
Controller::join_links(Director::absoluteBaseURL(), $url)
);
$obj = Convert::xml2array($response->getBody());
$this->assertEquals($obj['ID'], $comment1->ID);
$this->assertEquals($obj['Comment'], 'updated');
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testHTTPAcceptAndContentType() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$headers = array('Accept' => 'application/json');
$response = Director::test($url, null, null, 'GET', null, $headers);
$this->assertEquals($response->getStatusCode(), 200); // Success
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($obj->ID, $comment1->ID);
$this->assertEquals($response->getHeader('Content-Type'), 'application/json');
}
public function testNotFound(){
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$url = "/api/v1/RestfulServerTest_Comment/99";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 404);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testMethodNotAllowed() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'UNKNOWNHTTPMETHOD');
$this->assertEquals($response->getStatusCode(), 405);
}
public function testConflictOnExistingResourceWhenUsingPost() {
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating', 'rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$response = Director::test($url, null, null, 'POST');
$this->assertEquals($response->getStatusCode(), 409);
}
public function testUnsupportedMediaType() {
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$url = "/api/v1/RestfulServerTest_Comment";
$data = "Comment||\/||updated"; // weird format
$headers = array('Content-Type' => 'text/weirdformat');
$response = Director::test($url, null, null, 'POST', $data, $headers);
$this->assertEquals($response->getStatusCode(), 415);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testXMLValueFormatting() {
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating','rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<ID>' . $rating1->ID . '</ID>', $response->getBody());
$this->assertContains('<Rating>' . $rating1->Rating . '</Rating>', $response->getBody());
}
public function testApiAccessFieldRestrictions() {
$author1 = $this->objFromFixture('RestfulServerTest_Author','author1');
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating','rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<ID>', $response->getBody());
$this->assertContains('<Rating>', $response->getBody());
$this->assertContains('<Author', $response->getBody());
$this->assertNotContains('<SecretField>', $response->getBody());
$this->assertNotContains('<SecretRelation>', $response->getBody());
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID . '?add_fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains('<SecretField>', $response->getBody(),
'"add_fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertNotContains('<SecretRelation>', $response->getBody(),
'"add_fields" URL parameter filters out disallowed relations from $api_access'
);
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID . '?fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains('<SecretField>', $response->getBody(),
'"fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertNotContains('<SecretRelation>', $response->getBody(),
'"fields" URL parameter filters out disallowed relations from $api_access'
);
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . '/Ratings';
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<Rating>', $response->getBody(),
'Relation viewer shows fields allowed through $api_access'
);
$this->assertNotContains('<SecretField>', $response->getBody(),
'Relation viewer on has-many filters out disallowed fields from $api_access'
);
}
public function testApiAccessRelationRestrictionsInline() {
$author1 = $this->objFromFixture('RestfulServerTest_Author','author1');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains('<RelatedPages', $response->getBody(), 'Restricts many-many with api_access=false');
$this->assertNotContains('<PublishedPages', $response->getBody(), 'Restricts has-many with api_access=false');
}
public function testApiAccessRelationRestrictionsOnEndpoint() {
$author1 = $this->objFromFixture('RestfulServerTest_Author','author1');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . "/ProfilePage";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts has-one with api_access=false');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . "/RelatedPages";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts many-many with api_access=false');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . "/PublishedPages";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts has-many with api_access=false');
}
public function testApiAccessWithPUT() {
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating','rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$data = array(
'Rating' => '42',
'WriteProtectedField' => 'haxx0red'
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals($responseArr['Rating'], 42);
$this->assertNotEquals($responseArr['WriteProtectedField'], 'haxx0red');
}
protected function setUp(): void
{
parent::setUp();
Director::config()->set('alternate_base_url', $this->baseURI);
$this->logOut();
}
public function testJSONDataFormatter() {
$formatter = new JSONDataFormatter();
$editor = $this->objFromFixture('Member', 'editor');
$user = $this->objFromFixture('Member', 'user');
public function testApiAccess()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$page1 = $this->objFromFixture(RestfulServerTestPage::class, 'page1');
$this->assertEquals(
$formatter->convertDataObject($editor, array("FirstName", "Email")),
'{"FirstName":"Editor","Email":"editor@test.com"}',
"Correct JSON formatting with field subset");
// normal GET should succeed with $api_access enabled
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$set = DataObject::get(
"Member",
sprintf('"Member"."ID" IN (%s)', implode(',', array($editor->ID, $user->ID))),
'"Email" ASC' // for sorting for postgres
);
$this->assertEquals(
$formatter->convertDataObjectSet($set, array("FirstName", "Email")),
'{"totalSize":null,"items":[{"FirstName":"Editor","Email":"editor@test.com"},' .
'{"FirstName":"User","Email":"user@test.com"}]}',
"Correct JSON formatting on a dataobjectset with field filter");
}
public function testApiAccessWithPOST() {
$url = "/api/v1/RestfulServerTest_AuthorRating";
$data = array(
'Rating' => '42',
'WriteProtectedField' => 'haxx0red'
);
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals($responseArr['Rating'], 42);
$this->assertNotEquals($responseArr['WriteProtectedField'], 'haxx0red');
}
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
public function testCanViewRespectedInList() {
// Default content type
$url = "/api/v1/RestfulServerTest_SecretThing/";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
$this->assertNotContains('Unspeakable', $response->getBody());
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
// JSON content type
$url = "/api/v1/RestfulServerTest_SecretThing.json";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
$this->assertNotContains('Unspeakable', $response->getBody());
// even with logged in user a GET with $api_access disabled should fail
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestPage::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $page1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(401, $response->getStatusCode());
// With authentication
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$url = "/api/v1/RestfulServerTest_SecretThing/";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
$this->assertContains('Unspeakable', $response->getBody());
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testApiAccessBoolean()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertStringContainsString('<ID>', $response->getBody());
$this->assertStringContainsString('<Name>', $response->getBody());
$this->assertStringContainsString('<Comment>', $response->getBody());
$this->assertStringContainsString('<Page', $response->getBody());
$this->assertStringContainsString('<Author', $response->getBody());
}
public function testAuthenticatedGET()
{
$thing1 = $this->objFromFixture(RestfulServerTestSecretThing::class, 'thing1');
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
// @todo create additional mock object with authenticated VIEW permissions
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestSecretThing::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $thing1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(401, $response->getStatusCode());
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testGETWithFieldAlias()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(3, $responseArr['rate']);
}
public function testAuthenticatedPUT()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$data = array('Comment' => 'created');
$response = Director::test($url, $data, null, 'PUT');
$this->assertEquals(401, $response->getStatusCode()); // Permission failure
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$response = Director::test($url, $data, null, 'PUT');
$this->assertEquals(202, $response->getStatusCode()); // Accepted
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testGETRelationshipsXML()
{
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$rating2 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating2');
// @todo should be set up by fixtures, doesn't work for some reason...
$author1->Ratings()->add($rating1);
$author1->Ratings()->add($rating2);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$xmlTagSafeClassName = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$ratingsArr = $responseArr['Ratings'][$xmlTagSafeClassName];
$this->assertEquals(2, count($ratingsArr ?? []));
$ratingIDs = array(
(int)$ratingsArr[0]['@attributes']['id'],
(int)$ratingsArr[1]['@attributes']['id']
);
$this->assertContains($rating1->ID, $ratingIDs);
$this->assertContains($rating2->ID, $ratingIDs);
}
public function testGETRelationshipsWithAlias()
{
// Alias do not currently work with Relationships
Config::inst()->set(RestfulServerTestAuthor::class, 'api_field_mapping', ['stars' => 'Ratings']);
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
// @todo should be set up by fixtures, doesn't work for some reason...
$author1->Ratings()->add($rating1);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . '?add_fields=stars';
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$xmlTagSafeClassName = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$this->assertTrue(array_key_exists('Ratings', $responseArr ?? []));
$this->assertFalse(array_key_exists('stars', $responseArr ?? []));
}
public function testGETManyManyRelationshipsXML()
{
// author4 has related authors author2 and author3
$author2 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author2');
$author3 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author3');
$author4 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author4');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author4->ID . '/RelatedAuthors';
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$formatter = new XMLDataFormatter();
$arr = $formatter->convertStringToArray($response->getBody());
$xmlSafeClassName = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$authorsArr = $arr[$xmlSafeClassName];
$this->assertEquals(2, count($authorsArr ?? []));
$ratingIDs = array(
(int)$authorsArr[0]['ID'],
(int)$authorsArr[1]['ID']
);
$this->assertContains($author2->ID, $ratingIDs);
$this->assertContains($author3->ID, $ratingIDs);
}
public function testPUTWithFormEncoded()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$body = 'Name=Updated Comment&Comment=updated';
$headers = array(
'Content-Type' => 'application/x-www-form-urlencoded'
);
$response = Director::test($url, null, null, 'PUT', $body, $headers);
$this->assertEquals(202, $response->getStatusCode()); // Accepted
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals($comment1->ID, $responseArr['ID']);
$this->assertEquals('updated', $responseArr['Comment']);
$this->assertEquals('Updated Comment', $responseArr['Name']);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPOSTWithFormEncoded()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname";
$body = 'Name=New Comment&Comment=created';
$headers = array(
'Content-Type' => 'application/x-www-form-urlencoded'
);
$response = Director::test($url, null, null, 'POST', $body, $headers);
$this->assertEquals(201, $response->getStatusCode()); // Created
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertTrue($responseArr['ID'] > 0);
$this->assertNotEquals($responseArr['ID'], $comment1->ID);
$this->assertEquals('created', $responseArr['Comment']);
$this->assertEquals('New Comment', $responseArr['Name']);
$this->assertEquals(
Controller::join_links($url, $responseArr['ID'] . '.xml'),
$response->getHeader('Location')
);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPostWithoutBodyReturnsNoContent()
{
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$url = "{$this->baseURI}/api/v1/" . RestfulServerTestComment::class;
$response = Director::test($url, null, null, 'POST');
$this->assertEquals('No Content', $response->getBody());
unset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']);
}
public function testPUTwithJSON()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
// by acceptance mimetype
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body, array(
'Content-Type'=>'application/json',
'Accept' => 'application/json'
));
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$obj = json_decode($response->getBody() ?? '');
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('updated', $obj->Comment);
// by extension
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/{$comment1->ID}.json";
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$this->assertEquals($url, $response->getHeader('Location'));
$obj = json_decode($response->getBody() ?? '');
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('updated', $obj->Comment);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPUTwithXML()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
// by mimetype
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$body = '<RestfulServerTestComment><Comment>updated</Comment></RestfulServerTestComment>';
$response = Director::test($url, null, null, 'PUT', $body, array('Content-Type'=>'text/xml'));
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$formatter = new XMLDataFormatter();
$obj = $formatter->convertStringToArray($response->getBody());
$this->assertEquals($comment1->ID, $obj['ID']);
$this->assertEquals('updated', $obj['Comment']);
// by extension
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/{$comment1->ID}.xml";
$body = '<RestfulServerTestComment><Comment>updated</Comment></RestfulServerTestComment>';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$this->assertEquals($url, $response->getHeader('Location'));
$formatter = new XMLDataFormatter();
$obj = $formatter->convertStringToArray($response->getBody());
$this->assertEquals($comment1->ID, $obj['ID']);
$this->assertEquals('updated', $obj['Comment']);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testHTTPAcceptAndContentType()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$headers = array('Accept' => 'application/json');
$response = Director::test($url, null, null, 'GET', null, $headers);
$this->assertEquals(200, $response->getStatusCode()); // Success
$obj = json_decode($response->getBody() ?? '');
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('application/json', $response->getHeader('Content-Type'));
}
public function testNotFound()
{
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/99";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode());
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testMethodNotAllowed()
{
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$response = Director::test($url, null, null, 'UNKNOWNHTTPMETHOD');
$this->assertEquals(405, $response->getStatusCode());
}
public function testConflictOnExistingResourceWhenUsingPost()
{
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'POST');
$this->assertEquals(409, $response->getStatusCode());
}
public function testUnsupportedMediaType()
{
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname";
$data = "Comment||\/||updated"; // weird format
$headers = array('Content-Type' => 'text/weirdformat');
$response = Director::test($url, null, null, 'POST', $data, $headers);
$this->assertEquals(415, $response->getStatusCode());
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testXMLValueFormatting()
{
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertStringContainsString('<ID>' . $rating1->ID . '</ID>', $response->getBody());
$this->assertStringContainsString('<Rating>' . $rating1->Rating . '</Rating>', $response->getBody());
}
public function testXMLValueFormattingWithFieldAlias()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertStringContainsString('<rate>' . $rating1->Rating . '</rate>', $response->getBody());
}
public function testApiAccessFieldRestrictions()
{
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertStringContainsString('<ID>', $response->getBody());
$this->assertStringContainsString('<Rating>', $response->getBody());
$this->assertStringContainsString('<Author', $response->getBody());
$this->assertStringNotContainsString('<SecretField>', $response->getBody());
$this->assertStringNotContainsString('<SecretRelation>', $response->getBody());
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID . '?add_fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertStringNotContainsString(
'<SecretField>',
$response->getBody(),
'"add_fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertStringNotContainsString(
'<SecretRelation>',
$response->getBody(),
'"add_fields" URL parameter filters out disallowed relations from $api_access'
);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID . '?fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertStringNotContainsString(
'<SecretField>',
$response->getBody(),
'"fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertStringNotContainsString(
'<SecretRelation>',
$response->getBody(),
'"fields" URL parameter filters out disallowed relations from $api_access'
);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . '/Ratings';
$response = Director::test($url, null, null, 'GET');
$this->assertStringContainsString(
'<Rating>',
$response->getBody(),
'Relation viewer shows fields allowed through $api_access'
);
$this->assertStringNotContainsString(
'<SecretField>',
$response->getBody(),
'Relation viewer on has-many filters out disallowed fields from $api_access'
);
}
public function testApiAccessRelationRestrictionsInline()
{
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertStringNotContainsString(
'<RelatedPages',
$response->getBody(),
'Restricts many-many with api_access=false'
);
$this->assertStringNotContainsString(
'<PublishedPages',
$response->getBody(),
'Restricts has-many with api_access=false'
);
}
public function testApiAccessRelationRestrictionsOnEndpoint()
{
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . "/ProfilePage";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts has-one with api_access=false');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . "/RelatedPages";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts many-many with api_access=false');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . "/PublishedPages";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts has-many with api_access=false');
}
public function testApiAccessWithPUT()
{
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$data = array(
'Rating' => '42',
'WriteProtectedField' => 'haxx0red'
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(42, $responseArr['Rating']);
$this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']);
}
public function testFieldAliasWithPUT()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
// Test input with original fieldname
$data = array(
'Rating' => '42',
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
// should output with aliased name
$this->assertEquals(42, $responseArr['rate']);
}
public function testJSONDataFormatter()
{
$formatter = new JSONDataFormatter();
$editor = $this->objFromFixture(Member::class, 'editor');
$user = $this->objFromFixture(Member::class, 'user');
// The DataFormatter performs canView calls
// these are `Member`s so we need to be ADMIN types
$this->logInWithPermission('ADMIN');
$this->assertEquals(
'{"FirstName":"Editor","Email":"editor@test.com"}',
$formatter->convertDataObject($editor, ["FirstName", "Email"]),
"Correct JSON formatting with field subset"
);
$set = Member::get()
->filter('ID', [$editor->ID, $user->ID])
->sort('"Email" ASC'); // for sorting for postgres
$this->assertEquals(
'{"totalSize":null,"items":[{"FirstName":"Editor","Email":"editor@test.com"},' .
'{"FirstName":"User","Email":"user@test.com"}]}',
$formatter->convertDataObjectSet($set, ["FirstName", "Email"]),
"Correct JSON formatting on a dataobjectset with field filter"
);
}
public function testJSONDataFormatterWithFieldAlias()
{
Config::inst()->set(Member::class, 'api_field_mapping', ['MyName' => 'FirstName']);
$formatter = new JSONDataFormatter();
$editor = $this->objFromFixture(Member::class, 'editor');
$user = $this->objFromFixture(Member::class, 'user');
// The DataFormatter performs canView calls
// these are `Member`s so we need to be ADMIN types
$this->logInWithPermission('ADMIN');
$set = Member::get()
->filter('ID', [$editor->ID, $user->ID])
->sort('"Email" ASC'); // for sorting for postgres
$this->assertEquals(
'{"totalSize":null,"items":[{"MyName":"Editor","Email":"editor@test.com"},' .
'{"MyName":"User","Email":"user@test.com"}]}',
$formatter->convertDataObjectSet($set, ["FirstName", "Email"]),
"Correct JSON formatting with field alias"
);
}
public function testGetWithSortDescending()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/{$urlSafeClassname}?sort=FirstName&dir=DESC&fields=FirstName";
$response = Director::test($url);
$formatter = new XMLDataFormatter();
$results = $formatter->convertStringToArray($response->getBody());
$this->assertSame('Author 4', $results[$urlSafeClassname][0]['FirstName']);
$this->assertSame('Author 3', $results[$urlSafeClassname][1]['FirstName']);
$this->assertSame('Author 2', $results[$urlSafeClassname][2]['FirstName']);
$this->assertSame('Author 1', $results[$urlSafeClassname][3]['FirstName']);
}
public function testGetWithSortAscending()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/{$urlSafeClassname}?sort=FirstName&dir=ASC&fields=FirstName";
$response = Director::test($url);
$formatter = new XMLDataFormatter();
$results = $formatter->convertStringToArray($response->getBody());
$this->assertSame('Author 1', $results[$urlSafeClassname][0]['FirstName']);
$this->assertSame('Author 2', $results[$urlSafeClassname][1]['FirstName']);
$this->assertSame('Author 3', $results[$urlSafeClassname][2]['FirstName']);
$this->assertSame('Author 4', $results[$urlSafeClassname][3]['FirstName']);
}
public function testGetSortsByIdWhenInvalidSortColumnIsProvided()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/{$urlSafeClassname}?sort=Surname&dir=DESC&fields=FirstName";
$response = Director::test($url);
$formatter = new XMLDataFormatter();
$results = $formatter->convertStringToArray($response->getBody());
$this->assertSame('Author 1', $results[$urlSafeClassname][0]['FirstName']);
$this->assertSame('Author 2', $results[$urlSafeClassname][1]['FirstName']);
$this->assertSame('Author 3', $results[$urlSafeClassname][2]['FirstName']);
$this->assertSame('Author 4', $results[$urlSafeClassname][3]['FirstName']);
}
public function testApiAccessWithPOST()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$data = [
'Rating' => '42',
'WriteProtectedField' => 'haxx0red'
];
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(42, $responseArr['Rating']);
$this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']);
}
public function testFieldAliasWithPOST()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$data = [
'rate' => '42',
];
$response = Director::test($url, $data, null, 'POST');
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(42, $responseArr['rate']);
}
public function testCanViewRespectedInList()
{
// Default content type
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestSecretThing::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringNotContainsString('Unspeakable', $response->getBody());
// JSON content type
$url = "{$this->baseURI}/api/v1/$urlSafeClassname.json";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringNotContainsString('Unspeakable', $response->getBody());
$responseArray = json_decode($response->getBody() ?? '', true);
$this->assertSame(0, $responseArray['totalSize']);
// With authentication
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestSecretThing::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringContainsString('Unspeakable', $response->getBody());
// Assumption: default formatter is XML
$formatter = new XMLDataFormatter();
$responseArray = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(1, $responseArray['@attributes']['totalSize']);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testValidationErrorWithPOST()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestValidationFailure::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$data = [
'Content' => 'Test',
];
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals('SilverStripe\\ORM\\ValidationException', $responseArr['type']);
}
public function testExceptionThrownWithPOST()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestExceptionThrown::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$data = [
'Content' => 'Test',
];
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(\Exception::class, $responseArr['type']);
}
public function testParseClassName()
{
$manyMany = RestfulServerTestAuthor::config()->get('many_many');
// simple syntax (many many standard)
$className = RestfulServer::parseRelationClass($manyMany['RelatedPages']);
$this->assertEquals(RestfulServerTestPage::class, $className);
// array syntax (many many through)
$className = RestfulServer::parseRelationClass($manyMany['SortedPages']);
$this->assertEquals(RestfulServerTestPage::class, $className);
}
}
/**
* Everybody can view comments, logged in members in the "users" group can create comments,
* but only "editors" can edit or delete them.
*
*/
class RestfulServerTest_Comment extends DataObject implements PermissionProvider,TestOnly {
static $api_access = true;
static $db = array(
"Name" => "Varchar(255)",
"Comment" => "Text"
);
static $has_one = array(
'Page' => 'RestfulServerTest_Page',
'Author' => 'RestfulServerTest_Author',
);
public function providePermissions(){
return array(
'EDIT_Comment' => 'Edit Comment Objects',
'CREATE_Comment' => 'Create Comment Objects',
'DELETE_Comment' => 'Delete Comment Objects',
);
}
public function canView($member = null) {
return true;
}
public function canEdit($member = null) {
return Permission::checkMember($member, 'EDIT_Comment');
}
public function canDelete($member = null) {
return Permission::checkMember($member, 'DELETE_Comment');
}
public function canCreate($member = null) {
return Permission::checkMember($member, 'CREATE_Comment');
}
}
class RestfulServerTest_SecretThing extends DataObject implements TestOnly,PermissionProvider{
static $api_access = true;
static $db = array(
"Name" => "Varchar(255)",
);
public function canView($member = null) {
return Permission::checkMember($member, 'VIEW_SecretThing');
}
public function providePermissions(){
return array(
'VIEW_SecretThing' => 'View Secret Things',
);
}
}
class RestfulServerTest_Page extends DataObject implements TestOnly {
static $api_access = false;
static $db = array(
'Title' => 'Text',
'Content' => 'HTMLText',
);
static $has_one = array(
'Author' => 'RestfulServerTest_Author',
);
static $has_many = array(
'TestComments' => 'RestfulServerTest_Comment'
);
static $belongs_many_many = array(
'RelatedAuthors' => 'RestfulServerTest_Author',
);
}
class RestfulServerTest_Author extends DataObject implements TestOnly {
static $api_access = true;
static $db = array(
'Name' => 'Text',
);
static $many_many = array(
'RelatedPages' => 'RestfulServerTest_Page',
'RelatedAuthors' => 'RestfulServerTest_Author',
);
static $has_many = array(
'PublishedPages' => 'RestfulServerTest_Page',
'Ratings' => 'RestfulServerTest_AuthorRating',
);
public function canView($member = null) {
return true;
}
}
class RestfulServerTest_AuthorRating extends DataObject implements TestOnly {
static $api_access = array(
'view' => array(
'Rating',
'WriteProtectedField',
'Author'
),
'edit' => array(
'Rating'
)
);
static $db = array(
'Rating' => 'Int',
'SecretField' => 'Text',
'WriteProtectedField' => 'Text',
);
static $has_one = array(
'Author' => 'RestfulServerTest_Author',
'SecretRelation' => 'RestfulServerTest_Author',
);
public function canView($member = null) {
return true;
}
public function canEdit($member = null) {
return true;
}
public function canCreate($member = null) {
return true;
}
}

View File

@ -1,4 +1,4 @@
Member:
SilverStripe\Security\Member:
editor:
FirstName: Editor
Email: editor@test.com
@ -7,60 +7,62 @@ Member:
FirstName: User
Email: user@test.com
Password: user
Group:
SilverStripe\Security\Group:
editorgroup:
Title: Editors
Code: editors
Members: =>Member.editor
Members: =>SilverStripe\Security\Member.editor
usergroup:
Title: Users
Code: users
Members: =>Member.user
Permission:
Members: =>SilverStripe\Security\Member.user
SilverStripe\Security\Permission:
perm1:
Code: CREATE_Comment
Group: =>Group.usergroup
Group: =>SilverStripe\Security\Group.usergroup
perm3:
Code: EDIT_Comment
Group: =>Group.editorgroup
Group: =>SilverStripe\Security\Group.editorgroup
perm4:
Code: DELETE_Comment
Group: =>Group.editorgroup
Group: =>SilverStripe\Security\Group.editorgroup
perm5:
Code: CREATE_Comment
Group: =>Group.editorgroup
Group: =>SilverStripe\Security\Group.editorgroup
perm6:
Code: VIEW_SecretThing
Group: =>Group.editorgroup
RestfulServerTest_Page:
Group: =>SilverStripe\Security\Group.editorgroup
SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage:
page1:
Title: Testpage without API Access
RestfulServerTest_Comment:
SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestComment:
comment1:
Name: Joe
Comment: This is a test comment
Page: =>RestfulServerTest_Page.page1
RestfulServerTest_Author:
Page: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage.page1
SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor:
author1:
FirstName: Author 1
author2:
FirstName: Author 2
author3:
Firstname: Author 3
FirstName: Author 3
author4:
FirstName: Author 4
RelatedAuthors: =>RestfulServerTest_Author.author2,=>RestfulServerTest_Author.author3
RestfulServerTest_AuthorRating:
RelatedAuthors:
- =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author2
- =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author3
SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthorRating:
rating1:
Rating: 3
WriteProtectedField: Dont overwrite me
SecretField: Dont look at me!
Author: =>RestfulServerTest_Author.author1
SecretRelation: =>RestfulServerTest_Author.author1
Author: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1
SecretRelation: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1
rating2:
Rating: 5
Author: =>RestfulServerTest_Author.author1
SecretRelation: =>RestfulServerTest_Author.author1
RestfulServerTest_SecretThing:
Author: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1
SecretRelation: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1
SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestSecretThing:
thing1:
Name: Unspeakable
Name: Unspeakable

View File

@ -0,0 +1,29 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class AuthorSortedPageRelation extends DataObject implements TestOnly
{
/**
* @var string
*/
private static $table_name = 'AuthorSortedPageRelation';
/**
* @var array
*/
private static $has_one = [
'Parent' => RestfulServerTestAuthor::class,
'SortedPage' => RestfulServerTestPage::class,
];
/**
* @var array
*/
private static $db = [
'Sort' => 'Int',
];
}

View File

@ -0,0 +1,32 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class JSONDataFormatterTypeTestObject extends DataObject implements TestOnly
{
/**
* @var string
*/
private static $table_name = 'JSONDataFormatterTypeTestObject';
/**
* @var array
*/
private static $db = [
'Name' => 'Varchar',
'Active' => 'Boolean',
'Sort' => 'Int',
'Average' => 'Float',
];
private static $has_one = [
'Parent' => JSONDataFormatterTypeTestObject::class,
];
private static $has_many = [
'Children' => JSONDataFormatterTypeTestObject::class,
];
}

View File

@ -0,0 +1,38 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class RestfulServerTestAuthor extends DataObject implements TestOnly
{
private static $api_access = true;
private static $table_name = 'RestfulServerTestAuthor';
private static $db = array(
'FirstName' => 'Text',
);
private static $many_many = array(
'RelatedPages' => RestfulServerTestPage::class,
'RelatedAuthors' => RestfulServerTestAuthor::class,
'SortedPages' => [
'through' => AuthorSortedPageRelation::class,
'from' => 'Parent',
'to' => 'SortedPage',
],
);
private static $has_many = array(
'PublishedPages' => RestfulServerTestPage::class,
'Ratings' => RestfulServerTestAuthorRating::class,
'SortedPagesRelation' => AuthorSortedPageRelation::class . '.Parent',
);
public function canView($member = null)
{
return true;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class RestfulServerTestAuthorRating extends DataObject implements TestOnly
{
private static $api_access = array(
'view' => array(
'Rating',
'WriteProtectedField',
'Author'
),
'edit' => array(
'Rating'
)
);
private static $table_name = 'RestfulServerTestAuthorRating';
private static $db = array(
'Rating' => 'Int',
'SecretField' => 'Text',
'WriteProtectedField' => 'Text',
);
private static $has_one = array(
'Author' => RestfulServerTestAuthor::class,
'SecretRelation' => RestfulServerTestAuthor::class,
);
public function canView($member = null)
{
return true;
}
public function canEdit($member = null)
{
return true;
}
public function canCreate($member = null, $context = array())
{
return true;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider;
/**
* Everybody can view comments, logged in members in the "users" group can create comments,
* but only "editors" can edit or delete them.
*
*/
class RestfulServerTestComment extends DataObject implements PermissionProvider, TestOnly
{
private static $api_access = true;
private static $table_name = 'RestfulServerTestComment';
private static $db = array(
"Name" => "Varchar(255)",
"Comment" => "Text"
);
private static $has_one = array(
'Page' => RestfulServerTestPage::class,
'Author' => RestfulServerTestAuthor::class,
);
public function providePermissions()
{
return array(
'EDIT_Comment' => 'Edit Comment Objects',
'CREATE_Comment' => 'Create Comment Objects',
'DELETE_Comment' => 'Delete Comment Objects',
);
}
public function canView($member = null)
{
return true;
}
public function canEdit($member = null)
{
return Permission::checkMember($member, 'EDIT_Comment');
}
public function canDelete($member = null)
{
return Permission::checkMember($member, 'DELETE_Comment');
}
public function canCreate($member = null, $context = array())
{
return Permission::checkMember($member, 'CREATE_Comment');
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
/**
* Class RestfulServerTestExceptionThrown
* @package SilverStripe\RestfulServer\Tests\Stubs
*
* @property string Content
* @property string Title
*/
class RestfulServerTestExceptionThrown extends DataObject implements TestOnly
{
private static $api_access = true;
private static $table_name = 'RestfulServerTestExceptionThrown';
private static $db = array(
'Content' => 'Text',
'Title' => 'Text',
);
public function onBeforeWrite()
{
parent::onBeforeWrite();
throw new \Exception('This is an exception test');
}
public function canView($member = null)
{
return true;
}
public function canEdit($member = null)
{
return true;
}
public function canDelete($member = null)
{
return true;
}
public function canCreate($member = null, $context = array())
{
return true;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class RestfulServerTestPage extends DataObject implements TestOnly
{
private static $api_access = false;
private static $table_name = 'RestfulServerTestPage';
private static $db = array(
'Title' => 'Text',
'Content' => 'HTMLText',
);
private static $has_one = array(
'Author' => RestfulServerTestAuthor::class,
);
private static $has_many = array(
'TestComments' => RestfulServerTestComment::class
);
private static $belongs_many_many = array(
'RelatedAuthors' => RestfulServerTestAuthor::class,
);
}

View File

@ -0,0 +1,31 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider;
class RestfulServerTestSecretThing extends DataObject implements TestOnly, PermissionProvider
{
private static $api_access = true;
private static $table_name = 'RestfulServerTestSecretThing';
private static $db = array(
"Name" => "Varchar(255)",
);
public function canView($member = null)
{
return Permission::checkMember($member, 'VIEW_SecretThing');
}
public function providePermissions()
{
return array(
'VIEW_SecretThing' => 'View Secret Things',
);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace SilverStripe\RestfulServer\Tests\Stubs;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
/**
* Class RestfulServerTestValidationFailure
* @package SilverStripe\RestfulServer\Tests\Stubs
*
* @property string Content
* @property string Title
*/
class RestfulServerTestValidationFailure extends DataObject implements TestOnly
{
private static $api_access = true;
private static $table_name = 'RestfulServerTestValidationFailure';
private static $db = array(
'Content' => 'Text',
'Title' => 'Text',
);
/**
* @return \SilverStripe\ORM\ValidationResult
*/
public function validate()
{
$result = parent::validate();
if (strlen($this->Content ?? '') === 0) {
$result->addFieldError('Content', 'Content required');
}
if (strlen($this->Title ?? '') === 0) {
$result->addFieldError('Title', 'Title required');
}
return $result;
}
public function canView($member = null)
{
return true;
}
public function canEdit($member = null)
{
return true;
}
public function canDelete($member = null)
{
return true;
}
public function canCreate($member = null, $context = array())
{
return true;
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace SilverStripe\RestfulServer\Tests;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\RestfulServer\DataFormatter\XMLDataFormatter;
use Exception;
class XMLDataFormatterTest extends SapphireTest
{
/**
* Tests {@link Convert::xml2array()}
*/
public function testConvertStringToArray()
{
$inputXML = <<<XML
<?xml version="1.0"?>
<!DOCTYPE results [
<!ENTITY long "SOME_SUPER_LONG_STRING">
]>
<results>
<result>My para</result>
<result>Ampersand &amp; is retained and not double encoded</result>
</results>
XML
;
$expected = [
'result' => [
'My para',
'Ampersand & is retained and not double encoded'
]
];
$formatter = new XMLDataFormatter();
$actual = $formatter->convertStringToArray($inputXML);
$this->assertEquals($expected, $actual);
}
/**
* Tests {@link Convert::xml2array()} if an exception the contains a reference to a removed <!ENTITY />
*/
public function testConvertStringToArrayEntityException()
{
$inputXML = <<<XML
<?xml version="1.0"?>
<!DOCTYPE results [
<!ENTITY long "SOME_SUPER_LONG_STRING">
]>
<results>
<result>Now include &long; lots of times to expand the in-memory size of this XML structure</result>
<result>&long;&long;&long;</result>
</results>
XML;
$this->expectException(Exception::class);
$this->expectExceptionMessage('String could not be parsed as XML');
$formatter = new XMLDataFormatter();
$formatter->convertStringToArray($inputXML);
}
/**
* Tests {@link Convert::xml2array()} if an exception the contains a reference to a multiple removed <!ENTITY />
*/
public function testConvertStringToArrayMultipleEntitiesException()
{
$inputXML = <<<XML
<?xml version="1.0"?>
<!DOCTYPE results [<!ENTITY long "SOME_SUPER_LONG_STRING"><!ENTITY short "SHORT_STRING">]>
<results>
<result>Now include &long; and &short; lots of times</result>
<result>&long;&long;&long;&short;&short;&short;</result>
</results>
XML;
$this->expectException(Exception::class);
$this->expectExceptionMessage('String could not be parsed as XML');
$formatter = new XMLDataFormatter();
$formatter->convertStringToArray($inputXML);
}
}