Compare commits
129 Commits
Author | SHA1 | Date |
---|---|---|
Guy Sartorelli | 5170146d07 | |
Guy Sartorelli | e5c349b1b1 | |
Maxime Rainville | 2a122a32ff | |
Steve Boyd | 5a51aebc89 | |
Guy Sartorelli | 729494737d | |
Guy Sartorelli | 45883df8c0 | |
Guy Sartorelli | 1376bb4d41 | |
Sabina Talipova | 61fe0c71c3 | |
Steve Boyd | bdafc33228 | |
Steve Boyd | 3dc0eb0828 | |
Steve Boyd | 32a5fd1db9 | |
Guy Sartorelli | 6f79feac51 | |
Steve Boyd | 31256f148a | |
Steve Boyd | abf06ba300 | |
Steve Boyd | 06b5522aca | |
Guy Sartorelli | c2498f9cf6 | |
Steve Boyd | bc6e0ff8c4 | |
Guy Sartorelli | fa77f23f66 | |
Steve Boyd | 5bff584246 | |
Maxime Rainville | 94bad49802 | |
Steve Boyd | e0bab7d623 | |
Maxime Rainville | e84a66dad3 | |
Steve Boyd | 4a2716cd7d | |
Steve Boyd | c11b7cdcda | |
Maxime Rainville | 88c509b0d1 | |
Steve Boyd | 3da21bb6c2 | |
Steve Boyd | 0cac389a9f | |
Maxime Rainville | 96654b86b2 | |
Steve Boyd | 77be6bc44b | |
Maxime Rainville | 6e62e846ce | |
Robbie Averill | 1e4fe6fbbe | |
Steve Boyd | 1a2527d210 | |
Garion Herman | 444d1aa14f | |
Maxime Rainville | ac0b263683 | |
Robbie Averill | b3acde80f6 | |
Robbie Averill | 2fa54bc101 | |
Sander Hagenaars | 0b734c21c6 | |
Robbie Averill | e0f4e5684f | |
Robbie Averill | 89c811b295 | |
Robbie Averill | bb45d27869 | |
Robbie Averill | 71865f60a4 | |
Robbie Averill | a9507a7886 | |
Robbie Averill | 5505f93875 | |
Robbie Averill | aed6575e89 | |
Robbie Averill | a3319831a8 | |
Robbie Averill | 284aceddd0 | |
Robbie Averill | 165e1d4794 | |
Robbie Averill | dbb8e18644 | |
Robbie Averill | b44203b800 | |
User for performing fabric deployments | 498402389c | |
Guy Marriott | 57c0597db3 | |
Robbie Averill | 2390698ea9 | |
Robbie Averill | 080ce4015b | |
Dylan Wagstaff | c8ddec1ecb | |
Robbie Averill | 1e09707cc0 | |
Robbie Averill | 11ac9d142e | |
Russ Michell | 9e923d6f9e | |
Russ Michell | 8e4fbd0636 | |
Robbie Averill | 489f8c576f | |
andreaspiening | cacf25fb9b | |
andreaspiening | 73c61e7d4c | |
Robbie Averill | 9243546b75 | |
Andreas Piening | 4ec6eb4db0 | |
Robbie Averill | ee37e6c896 | |
Mojmir Fendek | a60751bd74 | |
Robbie Averill | 3c1055e2f0 | |
Andreas Piening | cbca821c9b | |
Andreas Piening | d0149f8995 | |
Robbie Averill | 5f7861e0ac | |
Robbie Averill | eaef7a5ea4 | |
Robbie Averill | f54142e661 | |
Robbie Averill | bb000254af | |
Robbie Averill | 49f12cb31d | |
Andreas Piening | 9cfe4f343d | |
Andreas Piening | d45a407185 | |
Robbie Averill | 4581fbf479 | |
Bernard Hamlin | 5c52bf0c53 | |
Bernard Hamlin | aada3e350f | |
cpenny | 44c5b45748 | |
Robbie Averill | c30b72e058 | |
cpenny | 029ccd0a38 | |
Robbie Averill | b59d956143 | |
Robbie Averill | 5b58220367 | |
Robbie Averill | defdc72bbd | |
Robbie Averill | 9e4b2ff59b | |
Raissa North | 334553c779 | |
Robbie Averill | 852ca334b8 | |
Raissa North | 823d29f94a | |
Robbie Averill | 47ec185c12 | |
Dylan Wagstaff | 3dda824796 | |
Dylan Wagstaff | 6601b42c4b | |
Robbie Averill | e5a757d589 | |
Dylan Wagstaff | a737f67a13 | |
Dylan Wagstaff | 4bdd071354 | |
Robbie Averill | be255c2af1 | |
Robbie Averill | b3fc6803fd | |
Daniel Hensby | 4ba5bf5853 | |
Robbie Averill | e54e23ede0 | |
Robbie Averill | cb92696392 | |
Franco Springveldt | a12e6d48f5 | |
Daniel Hensby | ba7b5bce1d | |
Robbie Averill | c54b4d345d | |
Damian Mooyman | f6025eac1d | |
Damian Mooyman | 09662b678e | |
helpfulrobot | 24bc82b0ac | |
Daniel Hensby | 90de9c2260 | |
Daniel Hensby | 78374ee57c | |
Daniel Hensby | 5e28d7bc25 | |
helpfulrobot | 90a1f63ae6 | |
helpfulrobot | e69554810d | |
helpfulrobot | 96f868dbf5 | |
Daniel Hensby | de3dc96d39 | |
Daniel Hensby | c05dc87578 | |
Daniel Hensby | 71a0ba660b | |
Daniel Hensby | e12bf7c63e | |
Daniel Hensby | 3b16d0c9a3 | |
helpfulrobot | 4a361e0cc6 | |
helpfulrobot | c7362296da | |
helpfulrobot | 53eaa3a6fa | |
Damian Mooyman | a75429913c | |
helpfulrobot | 775b2c6a21 | |
helpfulrobot | 94315960a6 | |
scott1702 | edeb42259b | |
Scott Hutchinson | 5b82868432 | |
Damian Mooyman | 1c7750e197 | |
Daniel Hensby | b3e3b4569d | |
Daniel Hensby | ad7446c10b | |
Damian Mooyman | de94422bc8 | |
Daniel Hensby | 2ef79fcc3e |
|
@ -0,0 +1,17 @@
|
|||
# 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 = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{*.yml,package.json,*.js}]
|
||||
indent_size = 2
|
||||
|
||||
# The indent size used in the package.json file cannot be changed:
|
||||
# https://github.com/npm/npm/pull/3180#issuecomment-16336516
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
30
.travis.yml
30
.travis.yml
|
@ -1,30 +0,0 @@
|
|||
language: php
|
||||
php:
|
||||
- 5.3
|
||||
- 5.4
|
||||
|
||||
env:
|
||||
- DB=MYSQL CORE_RELEASE=3.0
|
||||
- DB=MYSQL CORE_RELEASE=master
|
||||
- DB=PGSQL CORE_RELEASE=master
|
||||
- DB=SQLITE CORE_RELEASE=master
|
||||
|
||||
matrix:
|
||||
exclude:
|
||||
- php: 5.4
|
||||
env: DB=PGSQL CORE_RELEASE=master
|
||||
- php: 5.4
|
||||
env: DB=SQLITE CORE_RELEASE=master
|
||||
- php: 5.4
|
||||
env: DB=MYSQL CORE_RELEASE=3.0
|
||||
|
||||
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
|
|
@ -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
24
LICENSE
|
@ -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
113
README.md
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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).
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
comment: false
|
|
@ -1,20 +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.*"
|
||||
}
|
||||
}
|
||||
"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"
|
||||
}
|
|
@ -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.
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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')));
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
@ -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();
|
|
@ -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';
|
|
@ -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
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 & 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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue