diff --git a/.editorconfig b/.editorconfig index 47ae637..2d33794 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[{*.yml,package.json}] +[{*.yml,package.json,*.js}] indent_size = 2 # The indent size used in the package.json file cannot be changed: diff --git a/.gitattributes b/.gitattributes index 475f5f2..89eb187 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,4 @@ /.gitignore export-ignore /.travis.yml export-ignore /.scrutinizer.yml export-ignore +/codecov.yml export-ignore diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 61b0c9f..051ef9a 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,9 +1,15 @@ inherit: true +build: + nodes: + analysis: + tests: + override: [php-scrutinizer-run] + checks: php: code_rating: true duplication: true filter: - paths: [code/*, tests/*] + paths: [src/*, tests/*] diff --git a/.travis.yml b/.travis.yml index 0398f42..2f8ddda 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,33 @@ -# See https://github.com/silverstripe/silverstripe-travis-support for setup details - -sudo: false - language: php +env: + global: + - COMPOSER_ROOT_VERSION=2.0.x-dev + matrix: include: - - php: 5.4 - env: DB=MYSQL CORE_RELEASE=3.3 - - php: 5.5 - env: DB=MYSQL CORE_RELEASE=3.4 - php: 5.6 - env: DB=PGSQL CORE_RELEASE=3.5 + env: DB=MYSQL PHPCS_TEST=1 PHPUNIT_TEST=1 - php: 7.0 - env: DB=MYSQL CORE_RELEASE=3 + env: DB=MYSQL PHPUNIT_TEST=1 - php: 7.1 - env: DB=MYSQL CORE_RELEASE=3.6 + env: DB=PGSQL PHPUNIT_COVERAGE_TEST=1 before_script: - - composer self-update || true - - git clone git://github.com/silverstripe/silverstripe-travis-support.git ~/travis-support - - php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss - - cd ~/builds/ss - - composer install + # 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: - - vendor/bin/phpunit restfulserver/tests + - 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 --standard=vendor/silverstripe/framework/phpcs.xml.dist code/ tests/; fi + +after_success: + - if [[ $PHPUNIT_COVERAGE_TEST ]]; then bash <(curl -s https://codecov.io/bash) -f coverage.xml; fi diff --git a/.upgrade.yml b/.upgrade.yml new file mode 100644 index 0000000..0fee0b1 --- /dev/null +++ b/.upgrade.yml @@ -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\DataFormatter + FormEncodedDataFormatter: SilverStripe\RestfulServer\DataFormatter\FormEncodedDataFormatter + JSONDataFormatter: SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter + XMLDataFormatter: SilverStripe\RestfulServer\DataFormatter\XMLDataFormatter diff --git a/README.md b/README.md index 1fb7d70..99ec94b 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,63 @@ # SilverStripe RestfulServer Module -[![Build Status](https://secure.travis-ci.org/silverstripe/silverstripe-restfulserver.png)](http://travis-ci.org/silverstripe/silverstripe-restfulserver) +[![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) ## 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'] + ]; +} +``` ## Supported operations @@ -66,7 +90,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 +100,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. \ No newline at end of file +At the moment, only UTF-8 is supported. All output is made in UTF-8 regardless of Accept headers. diff --git a/_config.php b/_config.php deleted file mode 100644 index e69de29..0000000 diff --git a/_config/routes.yml b/_config/routes.yml index a9482aa..26ccc06 100644 --- a/_config/routes.yml +++ b/_config/routes.yml @@ -1,10 +1,6 @@ --- Name: restfulserverroutes -After: - - '#rootroutes' - - '#modelascontrollerroutes' --- -Director: +SilverStripe\Control\Director: rules: - 'api/v1/live': 'VersionedRestfulServer' - 'api/v1': 'RestfulServer' \ No newline at end of file + 'api/v1': 'SilverStripe\RestfulServer\RestfulServer' diff --git a/changelog.md b/changelog.md deleted file mode 100644 index fc4e05a..0000000 --- a/changelog.md +++ /dev/null @@ -1,20 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -This project adheres to [Semantic Versioning](http://semver.org/). - -## [1.0.5] - -* Converted to PSR-2 -* Added standard Scrutinizer config -* Added standard code of conduct -* Added standard editor config -* Added standard Travis config -* Added standard license -* Added standard git attributes - -## [1.0.4] - -* Changelog added. -* Include 3.2 and php 5.6 in tests \ No newline at end of file diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..69cb760 --- /dev/null +++ b/codecov.yml @@ -0,0 +1 @@ +comment: false diff --git a/composer.json b/composer.json index 482f89f..c83a1dd 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,9 @@ { "name": "silverstripe/restfulserver", "description": "Add a RESTful API to your SilverStripe application", - "type": "silverstripe-module", - "keywords": [ - "silverstripe", - "rest", - "api" - ], + "type": "silverstripe-vendormodule", + "keywords": ["silverstripe", "rest", "api"], + "license": "BSD-3-Clause", "authors": [ { "name": "Hamish Friedlander", @@ -18,12 +15,23 @@ } ], "require": { - "silverstripe/framework": "3.*" + "silverstripe/framework": "^4" + }, + "require-dev": { + "phpunit/PHPUnit": "^5.7", + "squizlabs/php_codesniffer": "^3.0" + }, + "autoload": { + "psr-4": { + "SilverStripe\\RestfulServer\\": "src", + "SilverStripe\\RestfulServer\\Tests\\": "tests" + } }, "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "2.x-dev" } }, - "license": "BSD-3-Clause" + "prefer-stable": true, + "minimum-stability": "dev" } diff --git a/license.md b/license.md index 9445c8e..8794670 100644 --- a/license.md +++ b/license.md @@ -1,4 +1,4 @@ -Copyright (c) 2016, SilverStripe Limited +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: diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1b984f8 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,9 @@ + + + CodeSniffer ruleset for SilverStripe coding conventions. + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f93a537 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + tests/ + + + + code/ + + tests/ + + + + diff --git a/code/BasicRestfulAuthenticator.php b/src/BasicRestfulAuthenticator.php similarity index 52% rename from code/BasicRestfulAuthenticator.php rename to src/BasicRestfulAuthenticator.php index 843a712..d59273d 100644 --- a/code/BasicRestfulAuthenticator.php +++ b/src/BasicRestfulAuthenticator.php @@ -1,5 +1,12 @@ $_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; + ]; + $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 false; + return $member; } } diff --git a/src/DataFormatter/DataFormatter.php b/src/DataFormatter/DataFormatter.php new file mode 100644 index 0000000..7fadd90 --- /dev/null +++ b/src/DataFormatter/DataFormatter.php @@ -0,0 +1,350 @@ +stat('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 = array(); + foreach ($classes as $class) { + $sortedClasses[$class] = singleton($class)->stat('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 + */ + public function setCustomFields($fields) + { + $this->customFields = $fields; + } + + /** + * @return array + */ + public function getCustomFields() + { + return $this->customFields; + } + + /** + * @param array $fields + */ + public function setCustomAddFields($fields) + { + $this->customAddFields = $fields; + } + + /** + * @param array $relations + */ + public function setCustomRelations($relations) + { + $this->customRelations = $relations; + } + + /** + * @return array + */ + public function getCustomRelations() + { + return $this->customRelations; + } + + /** + * @return array + */ + public function getCustomAddFields() + { + return $this->customAddFields; + } + + /** + * @param array $fields + */ + public function setRemoveFields($fields) + { + $this->removeFields = $fields; + } + + /** + * @return array + */ + public function getRemoveFields() + { + return $this->removeFields; + } + + public function getOutputContentType() + { + return $this->outputContentType; + } + + /** + * @param int $size + */ + public function setTotalSize($size) + { + $this->totalSize = (int)$size; + } + + /** + * @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 = array(); + + // 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) || $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, array('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. + */ + abstract public function convertDataObject(DataObjectInterface $do); + + /** + * Convert a data object set to this format. Return a string. + */ + abstract public function convertDataObjectSet(SS_List $set); + + /** + * @param string $strData HTTP Payload as string + */ + public function convertStringToArray($strData) + { + user_error('DataFormatter::convertStringToArray not implemented on subclass', E_USER_ERROR); + } +} diff --git a/src/DataFormatter/FormEncodedDataFormatter.php b/src/DataFormatter/FormEncodedDataFormatter.php new file mode 100644 index 0000000..6cc4ef6 --- /dev/null +++ b/src/DataFormatter/FormEncodedDataFormatter.php @@ -0,0 +1,46 @@ + + * 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 + * + * + * @todo Format response form encoded as well - currently uses XMLDataFormatter + * + * @author Cam Spiers + * + * @package framework + * @subpackage formatters + */ +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); + } +} diff --git a/src/DataFormatter/JSONDataFormatter.php b/src/DataFormatter/JSONDataFormatter.php new file mode 100644 index 0000000..1bf6c97 --- /dev/null +++ b/src/DataFormatter/JSONDataFormatter.php @@ -0,0 +1,170 @@ +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 = $obj->obj($fieldName)->forTemplate(); + $serobj->$fieldName = $fieldValue; + } + + if ($this->relationDepth > 0) { + foreach ($obj->hasOne() as $relName => $relClass) { + if (!singleton($relClass)->stat('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"); + } + $serobj->$relName = ArrayData::array_to_object(array( + "className" => $relClass, + "href" => "$href.json", + "id" => $obj->$fieldName + )); + } + + foreach ($obj->hasMany() + $obj->manyMany() as $relName => $relClass) { + //remove dot notation from relation names + $parts = explode('.', $relClass); + $relClass = array_shift($parts); + + if (!singleton($relClass)->stat('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) { + //$href = Director::absoluteURL($this->config()->api_base . "$className/$id/$relName/$item->ID"); + $href = Director::absoluteURL($this->config()->api_base . "$relClass/$item->ID"); + $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 Convert::array2json($serobj); + } + + public function convertStringToArray($strData) + { + return Convert::json2array($strData); + } +} diff --git a/src/DataFormatter/XMLDataFormatter.php b/src/DataFormatter/XMLDataFormatter.php new file mode 100644 index 0000000..dc70ad9 --- /dev/null +++ b/src/DataFormatter/XMLDataFormatter.php @@ -0,0 +1,202 @@ + 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 "\n" . $this->convertDataObjectWithoutHeader($obj, $fields); + } + + 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('', str_replace(']]>', ']]]]>', $fieldValue)); + } else { + $fieldValue = Convert::raw2xml($fieldValue); + } + $xml .= "<$fieldName>$fieldValue\n"; + } + } + + if ($this->relationDepth > 0) { + foreach ($obj->hasOne() as $relName => $relClass) { + if (!singleton($relClass)->stat('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 + . "\">\n"; + } + + foreach ($obj->hasMany() as $relName => $relClass) { + //remove dot notation from relation names + $parts = explode('.', $relClass); + $relClass = array_shift($parts); + if (!singleton($relClass)->stat('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}\">\n"; + } + } + $xml .= "\n"; + } + + foreach ($obj->manyMany() as $relName => $relClass) { + //remove dot notation from relation names + $parts = explode('.', $relClass); + $relClass = array_shift($parts); + if (!singleton($relClass)->stat('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}\">\n"; + } + } + $xml .= "\n"; + } + } + + $xml .= ""; + + 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 = "\n"; + $xml .= (is_numeric($this->totalSize)) ? "<$className totalSize=\"{$this->totalSize}\">\n" : "<$className>\n"; + foreach ($set as $item) { + $xml .= $this->convertDataObjectWithoutHeader($item, $fields); + } + $xml .= ""; + + return $xml; + } + + public function convertStringToArray($strData) + { + return Convert::xml2array($strData); + } +} diff --git a/code/RestfulServer.php b/src/RestfulServer.php similarity index 84% rename from code/RestfulServer.php rename to src/RestfulServer.php index bc48df1..18336e7 100644 --- a/code/RestfulServer.php +++ b/src/RestfulServer.php @@ -1,10 +1,26 @@ 'handleAction' + private static $url_handlers = array( + '$ClassName!/$ID/$Relation' => 'handleAction', + '' => 'notFound' #'$ClassName/#ID' => 'handleItem', #'$ClassName' => 'handleList', ); - protected static $api_base = "api/v1/"; + private static $api_base = "api/v1/"; - protected static $authenticator = 'BasicRestfulAuthenticator'; + private static $authenticator = BasicRestfulAuthenticator::class; /** * If no extension is given in the request, resolve to this extension @@ -44,7 +61,7 @@ class RestfulServer extends Controller * * @var string */ - public static $default_extension = "xml"; + private static $default_extension = "xml"; /** * If no extension is given, resolve the request to this mimetype. @@ -59,17 +76,18 @@ class RestfulServer extends Controller */ protected $member; - public static $allowed_actions = array( - 'index' + private static $allowed_actions = array( + 'index', + 'notFound' ); /* function handleItem($request) { - return new RestfulServer_Item(DataObject::get_by_id($request->param("ClassName"), $request->param("ID"))); + return new RestfulServerItem(DataObject::get_by_id($request->param("ClassName"), $request->param("ID"))); } function handleList($request) { - return new RestfulServer_List(DataObject::get($request->param("ClassName"),"")); + return new RestfulServerList(DataObject::get($request->param("ClassName"),"")); } */ @@ -79,24 +97,47 @@ class RestfulServer extends Controller * 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); + 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); + } + /** * This handler acts as the switchboard for the controller. * Since no $Action url-param is set, all requests are sent here. */ - public function index() + public function index(HTTPRequest $request) { - 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; + $className = $this->unsanitiseClassName($request->param('ClassName')); + $id = $request->param('ID') ?: null; + $relation = $request->param('Relation') ?: null; // Check input formats if (!class_exists($className)) { @@ -105,8 +146,7 @@ class RestfulServer extends Controller if ($id && !is_numeric($id)) { return $this->notFound(); } - if ( - $relation + if ($relation && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation) ) { return $this->notFound(); @@ -259,8 +299,12 @@ class RestfulServer extends Controller * @param array $params * @return SS_List */ - protected function getSearchQuery($className, $params = null, $sort = null, - $limit = null, $existingQuery = null + protected function getSearchQuery( + $className, + $params = null, + $sort = null, + $limit = null, + $existingQuery = null ) { if (singleton($className)->hasMethod('getRestfulSearchContext')) { $searchContext = singleton($className)->{'getRestfulSearchContext'}(); @@ -287,7 +331,7 @@ class RestfulServer extends Controller $accept = $this->request->getHeader('Accept'); $mimetypes = $this->request->getAcceptMimetypes(); if (!$className) { - $className = $this->urlParams['ClassName']; + $className = $this->unsanitiseClassName($this->request->param('ClassName')); } // get formatter @@ -393,6 +437,7 @@ class RestfulServer extends Controller if (!$obj) { return $this->notFound(); } + if (!$obj->canEdit($this->getMember())) { return $this->permissionFailure(); } @@ -424,7 +469,8 @@ class RestfulServer extends Controller $type = ".{$types[0]}"; } - $objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID" . $type); + $urlSafeClassName = $this->sanitiseClassName(get_class($obj)); + $objHref = Director::absoluteURL(self::$api_base . "$urlSafeClassName/$obj->ID" . $type); $this->getResponse()->addHeader('Location', $objHref); return $responseFormatter->convertDataObject($obj); @@ -493,7 +539,8 @@ class RestfulServer extends Controller $type = ".{$types[0]}"; } - $objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID" . $type); + $urlSafeClassName = $this->sanitiseClassName(get_class($obj)); + $objHref = Director::absoluteURL(self::$api_base . "$urlSafeClassName/$obj->ID" . $type); $this->getResponse()->addHeader('Location', $objHref); return $responseFormatter->convertDataObject($obj); @@ -529,7 +576,8 @@ class RestfulServer extends Controller // @todo Disallow editing of certain keys in database $data = array_diff_key($data, array('ID', 'Created')); - $apiAccess = singleton($this->urlParams['ClassName'])->stat('api_access'); + $className = $this->unsanitiseClassName($this->request->param('ClassName')); + $apiAccess = singleton($className)->config()->api_access; if (is_array($apiAccess) && isset($apiAccess['edit'])) { $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit'])); } @@ -579,9 +627,13 @@ class RestfulServer extends Controller { // The relation method will return a DataList, that getSearchQuery subsequently manipulates if ($obj->hasMethod($relationName)) { - if ($relationClass = $obj->has_one($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'; - $list = DataList::create($relationClass)->byIDs(array($obj->$joinField)); + // Again `byID` will return the wrong type for our purposes. So use `byIDs` + $list = DataList::create($relationClass)->byIDs([$obj->$joinField]); } else { $list = $obj->$relationName(); } @@ -633,8 +685,10 @@ class RestfulServer extends Controller */ protected function authenticate() { - $authClass = self::config()->authenticator; - return $authClass::authenticate(); + $authClass = $this->config()->authenticator; + $member = $authClass::authenticate(); + Security::setCurrentUser($member); + return $member; } /** @@ -649,9 +703,12 @@ class RestfulServer extends Controller { $allowedRelations = array(); $obj = singleton($class); - $relations = (array)$obj->has_one() + (array)$obj->has_many() + (array)$obj->many_many(); + $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany(); if ($relations) { foreach ($relations as $relName => $relClass) { + //remove dot notation from relation names + $parts = explode('.', $relClass); + $relClass = array_shift($parts); if (singleton($relClass)->stat('api_access')) { $allowedRelations[] = $relName; } @@ -667,59 +724,6 @@ class RestfulServer extends Controller */ protected function getMember() { - return Member::currentUser(); - } -} - -/** - * Restful server handler for a SS_List - * - * @package framework - * @subpackage api - */ -class RestfulServer_List -{ - public static $url_handlers = array( - '#ID' => 'handleItem', - ); - - public function __construct($list) - { - $this->list = $list; - } - - public 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 -{ - public 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 RestfulServer_List($relation); - } else { - return new RestfulServer_Item($relation); - } + return Security::getCurrentUser(); } } diff --git a/src/RestfulServerItem.php b/src/RestfulServerItem.php new file mode 100644 index 0000000..18efc34 --- /dev/null +++ b/src/RestfulServerItem.php @@ -0,0 +1,35 @@ + '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); + } + } +} diff --git a/src/RestfulServerList.php b/src/RestfulServerList.php new file mode 100644 index 0000000..ce725d1 --- /dev/null +++ b/src/RestfulServerList.php @@ -0,0 +1,26 @@ + 'handleItem', + ); + + public function __construct($list) + { + $this->list = $list; + } + + public function handleItem($request) + { + return new RestfulServerItem($this->list->getById($request->param('ID'))); + } +} diff --git a/tests/RestfulServerTest.php b/tests/RestfulServerTest.php new file mode 100644 index 0000000..39463ca --- /dev/null +++ b/tests/RestfulServerTest.php @@ -0,0 +1,575 @@ +set('alternate_base_url', $this->baseURI); + Security::setCurrentUser(null); + } + + public function testApiAccess() + { + $comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1'); + $page1 = $this->objFromFixture(RestfulServerTestPage::class, 'page1'); + + // normal GET should succeed with $api_access enabled + $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()); + + $_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 + $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()); + + 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->assertContains('', $response->getBody()); + $this->assertContains('', $response->getBody()); + $this->assertContains('', $response->getBody()); + $this->assertContains('getBody()); + $this->assertContains('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 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(200, $response->getStatusCode()); // Success + + 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()); + + $responseArr = Convert::xml2array($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 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()); + $arr = Convert::xml2array($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(200, $response->getStatusCode()); // Success + // Assumption: XML is default output + $responseArr = Convert::xml2array($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 + $responseArr = Convert::xml2array($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(200, $response->getStatusCode()); // Updated + $obj = Convert::json2obj($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(200, $response->getStatusCode()); // Updated + $this->assertEquals($url, $response->getHeader('Location')); + $obj = Convert::json2obj($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 = 'updated'; + $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($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 = 'updated'; + $response = Director::test($url, null, null, 'PUT', $body); + $this->assertEquals(200, $response->getStatusCode()); // Updated + $this->assertEquals($url, $response->getHeader('Location')); + $obj = Convert::xml2array($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 = Convert::json2obj($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->assertContains('' . $rating1->ID . '', $response->getBody()); + $this->assertContains('' . $rating1->Rating . '', $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->assertContains('', $response->getBody()); + $this->assertContains('', $response->getBody()); + $this->assertContains('getBody()); + $this->assertNotContains('', $response->getBody()); + $this->assertNotContains('', $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( + '', + $response->getBody(), + '"add_fields" URL parameter filters out disallowed fields from $api_access' + ); + $this->assertNotContains( + '', + $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->assertNotContains( + '', + $response->getBody(), + '"fields" URL parameter filters out disallowed fields from $api_access' + ); + $this->assertNotContains( + '', + $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->assertContains( + '', + $response->getBody(), + 'Relation viewer shows fields allowed through $api_access' + ); + $this->assertNotContains( + '', + $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->assertNotContains('getBody(), 'Restricts many-many with api_access=false'); + $this->assertNotContains('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 + $responseArr = Convert::xml2array($response->getBody()); + $this->assertEquals(42, $responseArr['Rating']); + $this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']); + } + + 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 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 + $responseArr = Convert::xml2array($response->getBody()); + $this->assertEquals(42, $responseArr['Rating']); + $this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']); + } + + 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->assertNotContains('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->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->assertContains('Unspeakable', $response->getBody()); + // Assumption: default formatter is XML + $responseArray = Convert::xml2array($response->getBody()); + $this->assertEquals(1, $responseArray['@attributes']['totalSize']); + unset($_SERVER['PHP_AUTH_USER']); + unset($_SERVER['PHP_AUTH_PW']); + } +} diff --git a/tests/RestfulServerTest.yml b/tests/RestfulServerTest.yml new file mode 100644 index 0000000..a9eb6eb --- /dev/null +++ b/tests/RestfulServerTest.yml @@ -0,0 +1,66 @@ +SilverStripe\Security\Member: + editor: + FirstName: Editor + Email: editor@test.com + Password: editor + user: + FirstName: User + Email: user@test.com + Password: user +SilverStripe\Security\Group: + editorgroup: + Title: Editors + Code: editors + Members: =>SilverStripe\Security\Member.editor + usergroup: + Title: Users + Code: users + Members: =>SilverStripe\Security\Member.user +SilverStripe\Security\Permission: + perm1: + Code: CREATE_Comment + Group: =>SilverStripe\Security\Group.usergroup + perm3: + Code: EDIT_Comment + Group: =>SilverStripe\Security\Group.editorgroup + perm4: + Code: DELETE_Comment + Group: =>SilverStripe\Security\Group.editorgroup + perm5: + Code: CREATE_Comment + Group: =>SilverStripe\Security\Group.editorgroup + perm6: + Code: VIEW_SecretThing + Group: =>SilverStripe\Security\Group.editorgroup +SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage: + page1: + Title: Testpage without API Access +SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestComment: + comment1: + Name: Joe + Comment: This is a test comment + Page: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage.page1 +SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor: + author1: + FirstName: Author 1 + author2: + FirstName: Author 2 + author3: + Firstname: Author 3 + author4: + FirstName: Author 4 + 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: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1 + SecretRelation: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1 + rating2: + Rating: 5 + Author: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1 + SecretRelation: =>SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor.author1 +SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestSecretThing: + thing1: + Name: Unspeakable diff --git a/tests/Stubs/RestfulServerTestAuthor.php b/tests/Stubs/RestfulServerTestAuthor.php new file mode 100644 index 0000000..8f16a07 --- /dev/null +++ b/tests/Stubs/RestfulServerTestAuthor.php @@ -0,0 +1,35 @@ + 'Text', + ); + + private static $many_many = array( + 'RelatedPages' => RestfulServerTestPage::class, + 'RelatedAuthors' => RestfulServerTestAuthor::class, + ); + + private static $has_many = array( + 'PublishedPages' => RestfulServerTestPage::class, + 'Ratings' => RestfulServerTestAuthorRating::class, + ); + + public function canView($member = null) + { + return true; + } +} diff --git a/tests/Stubs/RestfulServerTestAuthorRating.php b/tests/Stubs/RestfulServerTestAuthorRating.php new file mode 100644 index 0000000..87f2500 --- /dev/null +++ b/tests/Stubs/RestfulServerTestAuthorRating.php @@ -0,0 +1,49 @@ + 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; + } +} diff --git a/tests/Stubs/RestfulServerTestComment.php b/tests/Stubs/RestfulServerTestComment.php new file mode 100644 index 0000000..c780003 --- /dev/null +++ b/tests/Stubs/RestfulServerTestComment.php @@ -0,0 +1,61 @@ + "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'); + } +} diff --git a/tests/Stubs/RestfulServerTestPage.php b/tests/Stubs/RestfulServerTestPage.php new file mode 100644 index 0000000..7c3a725 --- /dev/null +++ b/tests/Stubs/RestfulServerTestPage.php @@ -0,0 +1,32 @@ + '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, + ); +} diff --git a/tests/Stubs/RestfulServerTestSecretThing.php b/tests/Stubs/RestfulServerTestSecretThing.php new file mode 100644 index 0000000..8faec95 --- /dev/null +++ b/tests/Stubs/RestfulServerTestSecretThing.php @@ -0,0 +1,31 @@ + "Varchar(255)", + ); + + public function canView($member = null) + { + return Permission::checkMember($member, 'VIEW_SecretThing'); + } + + public function providePermissions() + { + return array( + 'VIEW_SecretThing' => 'View Secret Things', + ); + } +} diff --git a/tests/phpcs/ruleset.xml b/tests/phpcs/ruleset.xml deleted file mode 100644 index 383e2f0..0000000 --- a/tests/phpcs/ruleset.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - CodeSniffer ruleset for SilverStripe coding conventions. - - - */css/* - css/* - - - thirdparty/* - - - 8 - - - - - - - - 7 - - - 8 - - - - - - - - - diff --git a/tests/travis/_config.php b/tests/travis/_config.php deleted file mode 100644 index e226259..0000000 --- a/tests/travis/_config.php +++ /dev/null @@ -1,24 +0,0 @@ - - -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 diff --git a/tests/unit/RestfulServerTest.php b/tests/unit/RestfulServerTest.php deleted file mode 100644 index ee6dfba..0000000 --- a/tests/unit/RestfulServerTest.php +++ /dev/null @@ -1,651 +0,0 @@ -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('', $response->getBody()); - $this->assertContains('', $response->getBody()); - $this->assertContains('', $response->getBody()); - $this->assertContains('getBody()); - $this->assertContains('getBody()); - } - - public function testAuthenticatedGET() - { - $thing1 = $this->objFromFixture('RestfulServerTest_SecretThing', 'thing1'); - $comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1'); - - // @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); - } - - 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 testPostWithoutBodyReturnsNoContent() - { - $_SERVER['PHP_AUTH_USER'] = 'editor@test.com'; - $_SERVER['PHP_AUTH_PW'] = 'editor'; - - $url = '/api/v1/RestfulServerTest_Comment'; - $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('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 = 'updated'; - $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 = '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::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('' . $rating1->ID . '', $response->getBody()); - $this->assertContains('' . $rating1->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('', $response->getBody()); - $this->assertContains('', $response->getBody()); - $this->assertContains('getBody()); - $this->assertNotContains('', $response->getBody()); - $this->assertNotContains('', $response->getBody()); - - $url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID . '?add_fields=SecretField,SecretRelation'; - $response = Director::test($url, null, null, 'GET'); - $this->assertNotContains('', $response->getBody(), - '"add_fields" URL parameter filters out disallowed fields from $api_access' - ); - $this->assertNotContains('', $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('', $response->getBody(), - '"fields" URL parameter filters out disallowed fields from $api_access' - ); - $this->assertNotContains('', $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('', $response->getBody(), - 'Relation viewer shows fields allowed through $api_access' - ); - $this->assertNotContains('', $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('getBody(), 'Restricts many-many with api_access=false'); - $this->assertNotContains('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'); - } - - public function testJSONDataFormatter() - { - $formatter = new JSONDataFormatter(); - $editor = $this->objFromFixture('Member', 'editor'); - $user = $this->objFromFixture('Member', 'user'); - - $this->assertEquals( - $formatter->convertDataObject($editor, array("FirstName", "Email")), - '{"FirstName":"Editor","Email":"editor@test.com"}', - "Correct JSON formatting with field subset"); - - $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'); - } - - 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()); - - // 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()); - $responseArray = Convert::json2array($response->getBody()); - $this->assertSame(0, $responseArray['totalSize']); - - // 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()); - // Assumption: default formatter is XML - $responseArray = Convert::xml2array($response->getBody()); - $this->assertEquals(1, $responseArray['@attributes']['totalSize']); - unset($_SERVER['PHP_AUTH_USER']); - unset($_SERVER['PHP_AUTH_PW']); - } -} - -/** - * 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 -{ - public static $api_access = true; - - public static $db = array( - "Name" => "Varchar(255)", - "Comment" => "Text" - ); - - public 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 -{ - public static $api_access = true; - - public 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 -{ - public static $api_access = false; - - public static $db = array( - 'Title' => 'Text', - 'Content' => 'HTMLText', - ); - - public static $has_one = array( - 'Author' => 'RestfulServerTest_Author', - ); - - public static $has_many = array( - 'TestComments' => 'RestfulServerTest_Comment' - ); - - public static $belongs_many_many = array( - 'RelatedAuthors' => 'RestfulServerTest_Author', - ); -} - -class RestfulServerTest_Author extends DataObject implements TestOnly -{ - public static $api_access = true; - - public static $db = array( - 'Name' => 'Text', - ); - - public static $many_many = array( - 'RelatedPages' => 'RestfulServerTest_Page', - 'RelatedAuthors' => 'RestfulServerTest_Author', - ); - - public static $has_many = array( - 'PublishedPages' => 'RestfulServerTest_Page', - 'Ratings' => 'RestfulServerTest_AuthorRating', - ); - - public function canView($member = null) - { - return true; - } -} - -class RestfulServerTest_AuthorRating extends DataObject implements TestOnly -{ - public static $api_access = array( - 'view' => array( - 'Rating', - 'WriteProtectedField', - 'Author' - ), - 'edit' => array( - 'Rating' - ) - ); - - public static $db = array( - 'Rating' => 'Int', - 'SecretField' => 'Text', - 'WriteProtectedField' => 'Text', - ); - - public 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; - } -} diff --git a/tests/unit/RestfulServerTest.yml b/tests/unit/RestfulServerTest.yml deleted file mode 100644 index bb12d62..0000000 --- a/tests/unit/RestfulServerTest.yml +++ /dev/null @@ -1,66 +0,0 @@ -Member: - editor: - FirstName: Editor - Email: editor@test.com - Password: editor - user: - FirstName: User - Email: user@test.com - Password: user -Group: - editorgroup: - Title: Editors - Code: editors - Members: =>Member.editor - usergroup: - Title: Users - Code: users - Members: =>Member.user -Permission: - perm1: - Code: CREATE_Comment - Group: =>Group.usergroup - perm3: - Code: EDIT_Comment - Group: =>Group.editorgroup - perm4: - Code: DELETE_Comment - Group: =>Group.editorgroup - perm5: - Code: CREATE_Comment - Group: =>Group.editorgroup - perm6: - Code: VIEW_SecretThing - Group: =>Group.editorgroup -RestfulServerTest_Page: - page1: - Title: Testpage without API Access -RestfulServerTest_Comment: - comment1: - Name: Joe - Comment: This is a test comment - Page: =>RestfulServerTest_Page.page1 -RestfulServerTest_Author: - author1: - FirstName: Author 1 - author2: - FirstName: Author 2 - author3: - Firstname: Author 3 - author4: - FirstName: Author 4 - RelatedAuthors: =>RestfulServerTest_Author.author2,=>RestfulServerTest_Author.author3 -RestfulServerTest_AuthorRating: - rating1: - Rating: 3 - WriteProtectedField: Dont overwrite me - SecretField: Dont look at me! - Author: =>RestfulServerTest_Author.author1 - SecretRelation: =>RestfulServerTest_Author.author1 - rating2: - Rating: 5 - Author: =>RestfulServerTest_Author.author1 - SecretRelation: =>RestfulServerTest_Author.author1 -RestfulServerTest_SecretThing: - thing1: - Name: Unspeakable \ No newline at end of file