Compare commits

...

65 Commits
2.0.4 ... 2

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

* removed wrong use statements from older project

* better docblock

* use correct obj ClassName in RestfulServer::updateDataObject

* added findClassNameEndpoint method, replaced getEndpointAlias()

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

* Better docblock

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

* Better docblock

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

* Return type hint in findClassNameEndpoint method

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

* renamed endpoint method to resolveEndpoint

* unsanitiseClassName in resolveEndpoint method

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

* better docbloc

* Remove unneccesary unsanitiseClassName call

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

* changed docblocks to satisfy codesniffer

* Drop PHP 5.6 and 7.0 from travis


Co-authored-by: Guy Marriott <guy@scopey.co.nz>
2019-07-15 10:03:51 +12:00
Robbie Averill e0f4e5684f Merge branch '2.1' 2019-06-28 16:25:24 +12:00
Robbie Averill 89c811b295 Merge branch '2.0' into 2.1 2019-06-28 16:24:21 +12:00
Robbie Averill bb45d27869 Update Travis build matrix 2019-06-28 16:13:59 +12:00
Robbie Averill 71865f60a4 Merge branch '2.1' 2019-06-11 14:09:26 +12:00
Robbie Averill a9507a7886 Merge branch '2.0' into 2.1 2019-06-11 14:09:15 +12:00
Robbie Averill 5505f93875 Use trusty dist for Travis builds 2019-06-11 14:09:07 +12:00
Robbie Averill aed6575e89 Merge branch '2.0' into 2.1 2019-06-11 12:05:29 +12:00
Robbie Averill 284aceddd0 Use trusty in Travis builds 2019-05-29 11:08:55 +12:00
Robbie Averill 165e1d4794
Merge pull request #75 from phptek/issue/70
FIX: Fixes #70 Added extension points for GET requests
2019-05-27 15:29:22 +12:00
Robbie Averill b44203b800
DOCS Fix formatting endpoint descriptions
[ci skip]
2019-05-16 10:30:11 +12:00
User for performing fabric deployments 498402389c FIX: Fixes #70 Added extension points for GET requests
- MINOR: Fixed typos
2019-05-07 14:08:32 +12:00
Guy Marriott 57c0597db3
Merge pull request #72 from creative-commoners/pulls/2.1/remove-json-methods
FIX Replace Convert JSON methods with json_* methods, deprecated from SilverStripe 4.4
2018-10-29 11:29:39 +13:00
Robbie Averill 2390698ea9 FIX Replace Convert JSON methods with json_* methods, deprecated from SilverStripe 4.4 2018-10-28 21:39:14 +00:00
Robbie Averill 080ce4015b Merge branch '2.1' 2018-07-26 15:12:19 +12:00
Dylan Wagstaff c8ddec1ecb Add supported module badge to readme (#71) 2018-06-18 10:42:37 +12:00
Robbie Averill 1e09707cc0 Remove obsolete branch alias 2018-06-11 15:36:41 +12:00
Robbie Averill 11ac9d142e Merge branch '2.0' 2018-06-06 10:34:43 +12:00
Russ Michell 8e4fbd0636 FIX: Fixes #63 Conditionally permit additional GET request in POST context. (#64) 2018-05-31 12:11:12 +12:00
Robbie Averill 489f8c576f Merge branch '2.0' 2018-05-25 15:03:11 +12:00
andreaspiening cacf25fb9b Fix infinite redirect after PUT (#62)
* Fix infinite redirect after PUT by changing requestMethod through responsecode
* Fix unit tests to reflect changes
* Use 202 instead of 303
2018-05-08 12:09:02 +12:00
andreaspiening 73c61e7d4c Cast SilverStripe types to appropriate JSON types (#60) 2018-04-19 15:54:43 +12:00
Robbie Averill 9243546b75
Merge pull request #59 from catalyst/jsondataformater-to-not-use-xml
FIX: fix JSONDataFormatter to not convert values to XML
2018-04-17 13:01:09 +12:00
Andreas Piening 4ec6eb4db0 FIX: fix JSONDataFormatter to not convert values to XML 2018-04-17 11:56:21 +12:00
Robbie Averill ee37e6c896
Merge pull request #57 from silverstripe-terraformers/feature/many_many_through_support
Many many through syntax support added.
2018-04-09 15:49:41 +12:00
Mojmir Fendek a60751bd74 Many many through syntax support added. 2018-04-09 12:26:24 +12:00
Robbie Averill 5f7861e0ac
Merge pull request #51 from silverstripe-terraformers/feature/catch-exception-and-extension-points
Added general Exception catch/response. Added extension points to response methods
2018-04-04 15:34:05 +12:00
Robbie Averill eaef7a5ea4 Merge branch '2.0' 2018-04-04 15:33:03 +12:00
cpenny 44c5b45748 Added general Exception catch/response. Added extension points to all response methods. 2018-03-07 11:41:27 +13:00
23 changed files with 755 additions and 197 deletions

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

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

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

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

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

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

View File

@ -1,15 +0,0 @@
inherit: true
build:
nodes:
analysis:
tests:
override: [php-scrutinizer-run]
checks:
php:
code_rating: true
duplication: true
filter:
paths: [src/*, tests/*]

View File

@ -1,33 +0,0 @@
language: php
env:
global:
- COMPOSER_ROOT_VERSION=2.0.x-dev
matrix:
include:
- php: 5.6
env: DB=MYSQL PHPCS_TEST=1 PHPUNIT_TEST=1
- php: 7.0
env: DB=MYSQL PHPUNIT_TEST=1
- php: 7.1
env: DB=PGSQL PHPUNIT_COVERAGE_TEST=1
before_script:
# Init PHP
- phpenv rehash
- phpenv config-rm xdebug.ini
# Install composer dependencies
- composer validate
- composer require --no-update silverstripe/recipe-core:1.0.x-dev
- if [[ $DB == PGSQL ]]; then composer require --no-update silverstripe/postgresql 2.0.x-dev; fi
- composer install --prefer-dist --no-interaction --no-progress --no-suggest --optimize-autoloader --verbose --profile
script:
- if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit; fi
- if [[ $PHPUNIT_COVERAGE_TEST ]]; then phpdbg -qrr vendor/bin/phpunit --coverage-clover=coverage.xml; fi
- if [[ $PHPCS_TEST ]]; then vendor/bin/phpcs src/ tests/ *.php; fi
after_success:
- if [[ $PHPUNIT_COVERAGE_TEST ]]; then bash <(curl -s https://codecov.io/bash) -f coverage.xml; fi

View File

@ -1,8 +1,7 @@
# SilverStripe RestfulServer Module
# Silverstripe RestfulServer Module
[![Build Status](https://travis-ci.org/silverstripe/silverstripe-restfulserver.svg?branch=master)](https://travis-ci.org/silverstripe/silverstripe-restfulserver)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/silverstripe/silverstripe-restfulserver/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/silverstripe/silverstripe-restfulserver/?branch=master)
[![codecov](https://codecov.io/gh/silverstripe/silverstripe-restfulserver/branch/master/graph/badge.svg)](https://codecov.io/gh/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
@ -13,9 +12,9 @@ applications.
## Requirements
* SilverStripe 4.0 or higher
* 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).
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
@ -107,9 +106,9 @@ Similarly, `PUT` or `POST` requests will have fields transformed from the alias
- `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

View File

@ -2,7 +2,11 @@
"name": "silverstripe/restfulserver",
"description": "Add a RESTful API to your SilverStripe application",
"type": "silverstripe-vendormodule",
"keywords": ["silverstripe", "rest", "api"],
"keywords": [
"silverstripe",
"rest",
"api"
],
"license": "BSD-3-Clause",
"authors": [
{
@ -15,10 +19,11 @@
}
],
"require": {
"silverstripe/framework": "^4"
"php": "^7.4 || ^8.0",
"silverstripe/framework": "^4.10"
},
"require-dev": {
"phpunit/PHPUnit": "^5.7",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.0",
"silverstripe/versioned": "^1"
},
@ -31,4 +36,4 @@
"extra": [],
"prefer-stable": true,
"minimum-stability": "dev"
}
}

View File

@ -2,6 +2,9 @@
<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" />

View File

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

View File

@ -103,7 +103,7 @@ abstract class DataFormatter
*/
protected function sanitiseClassName($className)
{
return str_replace('\\', '-', $className);
return str_replace('\\', '-', $className ?? '');
}
/**
@ -123,7 +123,7 @@ abstract class DataFormatter
arsort($sortedClasses);
foreach ($sortedClasses as $className => $priority) {
$formatter = new $className();
if (in_array($extension, $formatter->supportedExtensions())) {
if (in_array($extension, $formatter->supportedExtensions() ?? [])) {
return $formatter;
}
}
@ -163,7 +163,7 @@ abstract class DataFormatter
arsort($sortedClasses);
foreach ($sortedClasses as $className => $priority) {
$formatter = new $className();
if (in_array($mimeType, $formatter->supportedMimeTypes())) {
if (in_array($mimeType, $formatter->supportedMimeTypes() ?? [])) {
return $formatter;
}
}
@ -329,7 +329,10 @@ abstract class DataFormatter
$dbFields = array_merge($dbFields, ['ID' => 'Int']);
if (is_array($this->removeFields)) {
$dbFields = array_diff_key($dbFields, array_combine($this->removeFields, $this->removeFields));
$dbFields = array_diff_key(
$dbFields ?? [],
array_combine($this->removeFields ?? [], $this->removeFields ?? [])
);
}
return $dbFields;
@ -418,7 +421,7 @@ abstract class DataFormatter
public function getFieldAlias($className, $field)
{
$apiMapping = $this->getApiMapping($className);
$apiMapping = array_flip($apiMapping);
$apiMapping = array_flip($apiMapping ?? []);
return $this->getMappedKey($apiMapping, $field);
}
@ -448,7 +451,7 @@ abstract class DataFormatter
protected function getMappedKey($map, $key)
{
if (is_array($map)) {
if (array_key_exists($key, $map)) {
if (array_key_exists($key, $map ?? [])) {
return $map[$key];
} else {
return $key;

View File

@ -35,7 +35,7 @@ class FormEncodedDataFormatter extends XMLDataFormatter
public function convertStringToArray($strData)
{
$postArray = array();
parse_str($strData, $postArray);
parse_str($strData ?? '', $postArray);
return $postArray;
//TODO: It would be nice to implement this function in Convert.php
//return Convert::querystr2array($strData);

View File

@ -2,12 +2,14 @@
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
@ -50,7 +52,7 @@ class JSONDataFormatter extends DataFormatter
*/
public function convertArray($array)
{
return Convert::array2json($array);
return json_encode($array);
}
/**
@ -63,7 +65,7 @@ class JSONDataFormatter extends DataFormatter
*/
public function convertDataObject(DataObjectInterface $obj, $fields = null, $relations = null)
{
return Convert::array2json($this->convertDataObjectToJSONObject($obj, $fields, $relations));
return json_encode($this->convertDataObjectToJSONObject($obj, $fields, $relations));
}
/**
@ -84,26 +86,26 @@ class JSONDataFormatter extends DataFormatter
foreach ($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
// Field filtering
if ($fields && !in_array($fieldName, $fields)) {
if ($fields && !in_array($fieldName, $fields ?? [])) {
continue;
}
$fieldValue = $obj->obj($fieldName)->forTemplate();
$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 (!singleton($relClass)->stat('api_access')) {
if (!$relClass::config()->get('api_access')) {
continue;
}
// Field filtering
if ($fields && !in_array($relName, $fields)) {
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations)) {
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
if ($obj->$relName() && (!$obj->$relName()->exists() || !$obj->$relName()->canView())) {
@ -119,24 +121,26 @@ class JSONDataFormatter extends DataFormatter
$serobj->$relName = ArrayData::array_to_object(array(
"className" => $relClass,
"href" => "$href.json",
"id" => $obj->$fieldName
"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);
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (!singleton($relClass)->stat('api_access')) {
if (!$relClass::config()->get('api_access')) {
continue;
}
// Field filtering
if ($fields && !in_array($relName, $fields)) {
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations)) {
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
@ -182,7 +186,7 @@ class JSONDataFormatter extends DataFormatter
"items" => $items
));
return Convert::array2json($serobj);
return json_encode($serobj);
}
/**
@ -191,6 +195,21 @@ class JSONDataFormatter extends DataFormatter
*/
public function convertStringToArray($strData)
{
return Convert::json2array($strData);
return json_decode($strData ?? '', true);
}
public static function cast(FieldType\DBField $dbfield)
{
switch (true) {
case $dbfield instanceof FieldType\DBInt:
return (int)$dbfield->RAW();
case $dbfield instanceof FieldType\DBFloat:
return (float)$dbfield->RAW();
case $dbfield instanceof FieldType\DBBoolean:
return (bool)$dbfield->RAW();
case is_null($dbfield->RAW()):
return null;
}
return $dbfield->RAW();
}
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\RestfulServer\DataFormatter;
use SimpleXMLElement;
use SilverStripe\Control\Controller;
use SilverStripe\Core\Convert;
use SilverStripe\Dev\Debug;
@ -10,6 +11,8 @@ 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
@ -120,7 +123,7 @@ class XMLDataFormatter extends DataFormatter
$xml = "<$className href=\"$objHref.xml\">\n";
foreach ($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
// Field filtering
if ($fields && !in_array($fieldName, $fields)) {
if ($fields && !in_array($fieldName, $fields ?? [])) {
continue;
}
$fieldValue = $obj->obj($fieldName)->forTemplate();
@ -133,7 +136,7 @@ class XMLDataFormatter extends DataFormatter
} else {
if ('HTMLText' == $fieldType) {
// Escape HTML values using CDATA
$fieldValue = sprintf('<![CDATA[%s]]>', str_replace(']]>', ']]]]><![CDATA[>', $fieldValue));
$fieldValue = sprintf('<![CDATA[%s]]>', str_replace(']]>', ']]]]><![CDATA[>', $fieldValue ?? ''));
} else {
$fieldValue = Convert::raw2xml($fieldValue);
}
@ -144,15 +147,15 @@ class XMLDataFormatter extends DataFormatter
if ($this->relationDepth > 0) {
foreach ($obj->hasOne() as $relName => $relClass) {
if (!singleton($relClass)->stat('api_access')) {
if (!singleton($relClass)::config()->get('api_access')) {
continue;
}
// Field filtering
if ($fields && !in_array($relName, $fields)) {
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations)) {
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
@ -168,19 +171,19 @@ class XMLDataFormatter extends DataFormatter
foreach ($obj->hasMany() as $relName => $relClass) {
//remove dot notation from relation names
$parts = explode('.', $relClass);
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (!singleton($relClass)->stat('api_access')) {
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)) {
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations)) {
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
@ -196,20 +199,22 @@ class XMLDataFormatter extends DataFormatter
}
foreach ($obj->manyMany() as $relName => $relClass) {
$relClass = RestfulServer::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass);
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (!singleton($relClass)->stat('api_access')) {
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)) {
if ($fields && !in_array($relName, $fields ?? [])) {
continue;
}
if ($this->customRelations && !in_array($relName, $this->customRelations)) {
if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
continue;
}
@ -258,6 +263,79 @@ class XMLDataFormatter extends DataFormatter
*/
public function convertStringToArray($strData)
{
return Convert::xml2array($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;
}
}

View File

@ -42,6 +42,10 @@ use SilverStripe\Security\Security;
*/
class RestfulServer extends Controller
{
/**
* @config
* @var array
*/
private static $url_handlers = array(
'$ClassName!/$ID/$Relation' => 'handleAction',
'' => 'notFound'
@ -63,10 +67,36 @@ class RestfulServer extends Controller
* 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.
*
@ -108,7 +138,7 @@ class RestfulServer extends Controller
*/
protected function sanitiseClassName($className)
{
return str_replace('\\', '-', $className);
return str_replace('\\', '-', $className ?? '');
}
/**
@ -120,7 +150,33 @@ class RestfulServer extends Controller
*/
protected function unsanitiseClassName($className)
{
return str_replace('-', '\\', $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;
}
/**
@ -129,19 +185,19 @@ class RestfulServer extends Controller
*/
public function index(HTTPRequest $request)
{
$className = $this->unsanitiseClassName($request->param('ClassName'));
$className = $this->resolveClassName($request);
$id = $request->param('ID') ?: null;
$relation = $request->param('Relation') ?: null;
// Check input formats
if (!class_exists($className)) {
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)
&& !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation ?? '')
) {
return $this->notFound();
}
@ -155,21 +211,25 @@ class RestfulServer extends Controller
// 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);
}
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->isPOST()) {
return $this->postHandler($className, $id, $relation);
}
if ($this->request->isPUT()) {
return $this->putHandler($className, $id, $relation);
}
if ($this->request->isPUT()) {
return $this->putHandler($className, $id, $relation);
}
if ($this->request->isDELETE()) {
return $this->deleteHandler($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
@ -229,6 +289,10 @@ class RestfulServer extends Controller
'limit' => (int) $this->request->getVar('limit'),
];
if ($limit['limit'] === 0) {
$limit = null;
}
$params = $this->request->getVars();
$responseFormatter = $this->getResponseDataFormatter($className);
@ -266,7 +330,7 @@ class RestfulServer extends Controller
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$rawFields = $this->request->getVar('fields');
$realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields));
$realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields ?? ''));
$fields = $rawFields ? $realFields : null;
if ($obj instanceof SS_List) {
@ -277,6 +341,8 @@ class RestfulServer extends Controller
}
}
$responseFormatter->setTotalSize($objs->count());
$this->extend('updateRestfulGetHandler', $objs, $responseFormatter);
return $responseFormatter->convertDataObjectSet($objs, $fields);
}
@ -285,6 +351,8 @@ class RestfulServer extends Controller
return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
}
$this->extend('updateRestfulGetHandler', $obj, $responseFormatter);
return $responseFormatter->convertDataObject($obj, $fields);
}
@ -327,18 +395,18 @@ class RestfulServer extends Controller
{
$extension = $this->request->getExtension();
$contentTypeWithEncoding = $this->request->getHeader('Content-Type');
preg_match('/([^;]*)/', $contentTypeWithEncoding, $contentTypeMatches);
preg_match('/([^;]*)/', $contentTypeWithEncoding ?? '', $contentTypeMatches);
$contentType = $contentTypeMatches[0];
$accept = $this->request->getHeader('Accept');
$mimetypes = $this->request->getAcceptMimetypes();
if (!$className) {
$className = $this->unsanitiseClassName($this->request->param('ClassName'));
$className = $this->resolveClassName($this->request);
}
// get formatter
if (!empty($extension)) {
$formatter = DataFormatter::for_extension($extension);
} elseif ($includeAcceptHeader && !empty($accept) && strpos($accept, '*/*') === false) {
} elseif ($includeAcceptHeader && !empty($accept) && strpos($accept ?? '', '*/*') === false) {
$formatter = DataFormatter::for_mimetypes($mimetypes);
if (!$formatter) {
$formatter = DataFormatter::for_extension($this->config()->default_extension);
@ -355,11 +423,11 @@ class RestfulServer extends Controller
// set custom fields
if ($customAddFields = $this->request->getVar('add_fields')) {
$customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields));
$customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields ?? ''));
$formatter->setCustomAddFields($customAddFields);
}
if ($customFields = $this->request->getVar('fields')) {
$customFields = $formatter->getRealFields($className, explode(',', $customFields));
$customFields = $formatter->getRealFields($className, explode(',', $customFields ?? ''));
$formatter->setCustomFields($customFields);
}
$formatter->setCustomRelations($this->getAllowedRelations($className));
@ -466,14 +534,14 @@ class RestfulServer extends Controller
return $obj;
}
$this->getResponse()->setStatusCode(200); // Success
$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)) {
if (count($types ?? [])) {
$type = ".{$types[0]}";
}
@ -517,7 +585,7 @@ class RestfulServer extends Controller
}
if (!Config::inst()->get($className, 'allowed_actions') ||
!in_array($relation, Config::inst()->get($className, 'allowed_actions'))) {
!in_array($relation, Config::inst()->get($className, 'allowed_actions') ?? [])) {
return $this->permissionFailure();
}
@ -558,14 +626,19 @@ class RestfulServer extends Controller
// or else we'll use the default (XML)
$types = $responseFormatter->supportedExtensions();
$type = '';
if (count($types)) {
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);
// 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);
}
@ -597,7 +670,7 @@ class RestfulServer extends Controller
$rawdata = $this->request->postVars();
}
$className = $this->unsanitiseClassName($this->request->param('ClassName'));
$className = $obj->ClassName;
// update any aliased field names
$data = [];
foreach ($rawdata as $key => $value) {
@ -606,11 +679,11 @@ class RestfulServer extends Controller
}
// @todo Disallow editing of certain keys in database
$data = array_diff_key($data, ['ID', 'Created']);
$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']));
$data = array_intersect_key($data ?? [], array_combine($apiAccess['edit'] ?? [], $apiAccess['edit'] ?? []));
}
$obj->update($data);
@ -689,7 +762,11 @@ class RestfulServer extends Controller
$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.";
$response = "You don't have access to this item through the API.";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
@ -700,7 +777,11 @@ class RestfulServer extends Controller
// return a 404
$this->getResponse()->setStatusCode(404);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "That object wasn't found";
$response = "That object wasn't found";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
@ -710,7 +791,11 @@ class RestfulServer extends Controller
{
$this->getResponse()->setStatusCode(405);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "Method Not Allowed";
$response = "Method Not Allowed";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
@ -720,7 +805,11 @@ class RestfulServer extends Controller
{
$this->response->setStatusCode(415); // Unsupported Media Type
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "Unsupported Media Type";
$response = "Unsupported Media Type";
$this->extend(__FUNCTION__, $response);
return $response;
}
/**
@ -737,6 +826,28 @@ class RestfulServer extends Controller
'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);
}
@ -768,8 +879,10 @@ class RestfulServer extends Controller
$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);
$parts = explode('.', $relClass ?? '');
$relClass = array_shift($parts);
if (Config::inst()->get($relClass, 'api_access')) {
$allowedRelations[] = $relName;
@ -788,4 +901,19 @@ class RestfulServer extends Controller
{
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];
}
}

View File

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

View File

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

View File

@ -2,7 +2,9 @@
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;
@ -18,6 +20,7 @@ use SilverStripe\Dev\SapphireTest;
use SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter;
use Page;
use SilverStripe\Core\Config\Config;
use SilverStripe\RestfulServer\DataFormatter\XMLDataFormatter;
/**
*
@ -38,14 +41,16 @@ class RestfulServerTest extends SapphireTest
RestfulServerTestPage::class,
RestfulServerTestAuthor::class,
RestfulServerTestAuthorRating::class,
RestfulServerTestValidationFailure::class,
RestfulServerTestExceptionThrown::class,
];
protected function urlSafeClassname($classname)
{
return str_replace('\\', '-', $classname);
return str_replace('\\', '-', $classname ?? '');
}
protected function setUp()
protected function setUp(): void
{
parent::setUp();
Director::config()->set('alternate_base_url', $this->baseURI);
@ -84,11 +89,11 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestComment::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $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());
$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()
@ -122,7 +127,8 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(3, $responseArr['rate']);
}
@ -140,7 +146,7 @@ class RestfulServerTest extends SapphireTest
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$response = Director::test($url, $data, null, 'PUT');
$this->assertEquals(200, $response->getStatusCode()); // Success
$this->assertEquals(202, $response->getStatusCode()); // Accepted
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
@ -161,10 +167,11 @@ class RestfulServerTest extends SapphireTest
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$xmlTagSafeClassName = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$ratingsArr = $responseArr['Ratings'][$xmlTagSafeClassName];
$this->assertEquals(2, count($ratingsArr));
$this->assertEquals(2, count($ratingsArr ?? []));
$ratingIDs = array(
(int)$ratingsArr[0]['@attributes']['id'],
(int)$ratingsArr[1]['@attributes']['id']
@ -188,11 +195,12 @@ class RestfulServerTest extends SapphireTest
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$responseArr = Convert::xml2array($response->getBody());
$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));
$this->assertTrue(array_key_exists('Ratings', $responseArr ?? []));
$this->assertFalse(array_key_exists('stars', $responseArr ?? []));
}
public function testGETManyManyRelationshipsXML()
@ -206,11 +214,13 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author4->ID . '/RelatedAuthors';
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$arr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$arr = $formatter->convertStringToArray($response->getBody());
$xmlSafeClassName = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$authorsArr = $arr[$xmlSafeClassName];
$this->assertEquals(2, count($authorsArr));
$this->assertEquals(2, count($authorsArr ?? []));
$ratingIDs = array(
(int)$authorsArr[0]['ID'],
(int)$authorsArr[1]['ID']
@ -233,9 +243,10 @@ class RestfulServerTest extends SapphireTest
'Content-Type' => 'application/x-www-form-urlencoded'
);
$response = Director::test($url, null, null, 'PUT', $body, $headers);
$this->assertEquals(200, $response->getStatusCode()); // Success
$this->assertEquals(202, $response->getStatusCode()); // Accepted
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$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']);
@ -260,7 +271,8 @@ class RestfulServerTest extends SapphireTest
$response = Director::test($url, null, null, 'POST', $body, $headers);
$this->assertEquals(201, $response->getStatusCode()); // Created
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertTrue($responseArr['ID'] > 0);
$this->assertNotEquals($responseArr['ID'], $comment1->ID);
$this->assertEquals('created', $responseArr['Comment']);
@ -302,8 +314,8 @@ class RestfulServerTest extends SapphireTest
'Content-Type'=>'application/json',
'Accept' => 'application/json'
));
$this->assertEquals(200, $response->getStatusCode()); // Updated
$obj = Convert::json2obj($response->getBody());
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$obj = json_decode($response->getBody() ?? '');
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('updated', $obj->Comment);
@ -312,9 +324,9 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/{$comment1->ID}.json";
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals(200, $response->getStatusCode()); // Updated
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$this->assertEquals($url, $response->getHeader('Location'));
$obj = Convert::json2obj($response->getBody());
$obj = json_decode($response->getBody() ?? '');
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('updated', $obj->Comment);
@ -334,8 +346,9 @@ class RestfulServerTest extends SapphireTest
$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(200, $response->getStatusCode()); // Updated
$obj = Convert::xml2array($response->getBody());
$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']);
@ -344,9 +357,10 @@ class RestfulServerTest extends SapphireTest
$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(200, $response->getStatusCode()); // Updated
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$this->assertEquals($url, $response->getHeader('Location'));
$obj = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$obj = $formatter->convertStringToArray($response->getBody());
$this->assertEquals($comment1->ID, $obj['ID']);
$this->assertEquals('updated', $obj['Comment']);
@ -364,7 +378,7 @@ class RestfulServerTest extends SapphireTest
$headers = array('Accept' => 'application/json');
$response = Director::test($url, null, null, 'GET', null, $headers);
$this->assertEquals(200, $response->getStatusCode()); // Success
$obj = Convert::json2obj($response->getBody());
$obj = json_decode($response->getBody() ?? '');
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('application/json', $response->getHeader('Content-Type'));
}
@ -426,8 +440,8 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $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());
$this->assertStringContainsString('<ID>' . $rating1->ID . '</ID>', $response->getBody());
$this->assertStringContainsString('<Rating>' . $rating1->Rating . '</Rating>', $response->getBody());
}
public function testXMLValueFormattingWithFieldAlias()
@ -438,7 +452,7 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<rate>' . $rating1->Rating . '</rate>', $response->getBody());
$this->assertStringContainsString('<rate>' . $rating1->Rating . '</rate>', $response->getBody());
}
public function testApiAccessFieldRestrictions()
@ -449,21 +463,21 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $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());
$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->assertNotContains(
$this->assertStringNotContainsString(
'<SecretField>',
$response->getBody(),
'"add_fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertNotContains(
$this->assertStringNotContainsString(
'<SecretRelation>',
$response->getBody(),
'"add_fields" URL parameter filters out disallowed relations from $api_access'
@ -472,12 +486,12 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID . '?fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains(
$this->assertStringNotContainsString(
'<SecretField>',
$response->getBody(),
'"fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertNotContains(
$this->assertStringNotContainsString(
'<SecretRelation>',
$response->getBody(),
'"fields" URL parameter filters out disallowed relations from $api_access'
@ -486,12 +500,12 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . '/Ratings';
$response = Director::test($url, null, null, 'GET');
$this->assertContains(
$this->assertStringContainsString(
'<Rating>',
$response->getBody(),
'Relation viewer shows fields allowed through $api_access'
);
$this->assertNotContains(
$this->assertStringNotContainsString(
'<SecretField>',
$response->getBody(),
'Relation viewer on has-many filters out disallowed fields from $api_access'
@ -505,8 +519,16 @@ class RestfulServerTest extends SapphireTest
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $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');
$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()
@ -541,7 +563,8 @@ class RestfulServerTest extends SapphireTest
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(42, $responseArr['Rating']);
$this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']);
}
@ -558,7 +581,8 @@ class RestfulServerTest extends SapphireTest
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
// should output with aliased name
$this->assertEquals(42, $responseArr['rate']);
}
@ -619,7 +643,8 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/{$urlSafeClassname}?sort=FirstName&dir=DESC&fields=FirstName";
$response = Director::test($url);
$results = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$results = $formatter->convertStringToArray($response->getBody());
$this->assertSame('Author 4', $results[$urlSafeClassname][0]['FirstName']);
$this->assertSame('Author 3', $results[$urlSafeClassname][1]['FirstName']);
@ -633,7 +658,8 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/{$urlSafeClassname}?sort=FirstName&dir=ASC&fields=FirstName";
$response = Director::test($url);
$results = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$results = $formatter->convertStringToArray($response->getBody());
$this->assertSame('Author 1', $results[$urlSafeClassname][0]['FirstName']);
$this->assertSame('Author 2', $results[$urlSafeClassname][1]['FirstName']);
@ -648,7 +674,8 @@ class RestfulServerTest extends SapphireTest
$response = Director::test($url);
$results = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$results = $formatter->convertStringToArray($response->getBody());
$this->assertSame('Author 1', $results[$urlSafeClassname][0]['FirstName']);
$this->assertSame('Author 2', $results[$urlSafeClassname][1]['FirstName']);
@ -666,7 +693,8 @@ class RestfulServerTest extends SapphireTest
];
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(42, $responseArr['Rating']);
$this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']);
}
@ -680,7 +708,8 @@ class RestfulServerTest extends SapphireTest
'rate' => '42',
];
$response = Director::test($url, $data, null, 'POST');
$responseArr = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArr = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(42, $responseArr['rate']);
}
@ -691,14 +720,14 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$this->assertNotContains('Unspeakable', $response->getBody());
$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->assertNotContains('Unspeakable', $response->getBody());
$responseArray = Convert::json2array($response->getBody());
$this->assertStringNotContainsString('Unspeakable', $response->getBody());
$responseArray = json_decode($response->getBody() ?? '', true);
$this->assertSame(0, $responseArray['totalSize']);
// With authentication
@ -708,9 +737,10 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$this->assertContains('Unspeakable', $response->getBody());
$this->assertStringContainsString('Unspeakable', $response->getBody());
// Assumption: default formatter is XML
$responseArray = Convert::xml2array($response->getBody());
$formatter = new XMLDataFormatter();
$responseArray = $formatter->convertStringToArray($response->getBody());
$this->assertEquals(1, $responseArray['@attributes']['totalSize']);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
@ -725,7 +755,35 @@ class RestfulServerTest extends SapphireTest
];
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$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);
}
}

View File

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

View File

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

View File

@ -18,11 +18,17 @@ class RestfulServerTestAuthor extends DataObject implements TestOnly
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)

View File

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

View File

@ -30,11 +30,11 @@ class RestfulServerTestValidationFailure extends DataObject implements TestOnly
{
$result = parent::validate();
if (strlen($this->Content) === 0) {
if (strlen($this->Content ?? '') === 0) {
$result->addFieldError('Content', 'Content required');
}
if (strlen($this->Title) === 0) {
if (strlen($this->Title ?? '') === 0) {
$result->addFieldError('Title', 'Title required');
}

View File

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