MINOR Initial commit

This commit is contained in:
Ingo Schommer 2012-06-04 10:14:02 +02:00
commit 90bfa5eac6
6 changed files with 1326 additions and 0 deletions

24
LICENSE Normal file
View File

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

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# SilverStripe RestfulServer Module
## 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
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
## 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,
unless explicitly controlled through model permissions.
class Article extends DataObject {
static $db = array('Title'=>'Text','Published'=>'Boolean');
static $api_access = true;
}
Example DataObject with advanced api access, limiting viewing and editing to Title attribute only:
class Article extends DataObject {
static $db = array('Title'=>'Text','Published'=>'Boolean');
static $api_access = array(
'view' => array('Title'),
'edit' => array('Title'),
);
}
## Supported operations
- `GET /api/v1/(ClassName)/(ID)` - gets a database record
- `GET /api/v1/(ClassName)/(ID)/(Relation)` - get all of the records linked to this database record by the given reatlion
- `GET /api/v1/(ClassName)?(Field)=(Val)&(Field)=(Val)` - searches for matching database records
- `POST /api/v1/(ClassName)` - create a new database record
- `PUT /api/v1/(ClassName)/(ID)` - updates a database record
- `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)
## Search
You can trigger searches based on the fields specified on `DataObject::searchable_fields` and passed
through `DataObject::getDefaultSearchContext()`. Just add a key-value pair with the search-term
to the url, e.g. /api/v1/(ClassName)/?Title=mytitle.
## Other url-modifiers
- `&limit=<numeric>`: Limit the result set
- `&relationdepth=<numeric>`: Displays links to existing has-one and has-many relationships to a certain depth (Default: 1)
- `&fields=<string>`: Comma-separated list of fields on the output object (defaults to all database-columns).
Handy to limit output for bandwidth and performance reasons.
- `&sort=<myfield>&dir=<asc|desc>`
- `&add_fields=<string>`: Comma-separated list of additional fields, for example dynamic getters.
## Access control
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:
- `DataObject::canView()`
- `DataObject::canEdit()`
- `DataObject::canDelete()`
- `DataObject::canCreate()`
See `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.

0
_config.php Normal file
View File

589
code/RestfulServer.php Normal file
View File

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

567
tests/RestfulServerTest.php Normal file
View File

@ -0,0 +1,567 @@
<?php
/**
*
* @todo Test Relation getters
* @todo Test filter and limit through GET params
* @todo Test DELETE verb
*
*/
class RestfulServerTest extends SapphireTest {
static $fixture_file = 'RestfulServerTest.yml';
protected $extraDataObjects = array(
'RestfulServerTest_Comment',
'RestfulServerTest_SecretThing',
'RestfulServerTest_Page',
'RestfulServerTest_Author',
'RestfulServerTest_AuthorRating',
);
public function testApiAccess() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$page1 = $this->objFromFixture('RestfulServerTest_Page', 'page1');
// normal GET should succeed with $api_access enabled
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 200);
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
// even with logged in user a GET with $api_access disabled should fail
$url = "/api/v1/RestfulServerTest_Page/" . $page1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 401);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testApiAccessBoolean() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<ID>', $response->getBody());
$this->assertContains('<Name>', $response->getBody());
$this->assertContains('<Comment>', $response->getBody());
$this->assertContains('<Page', $response->getBody());
$this->assertContains('<Author', $response->getBody());
}
public function testAuthenticatedGET() {
$thing1 = $this->objFromFixture('RestfulServerTest_SecretThing', 'thing1');
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
// @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 testPUTwithJSON() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
// by mimetype
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body, array('Content-Type'=>'application/json'));
$this->assertEquals($response->getStatusCode(), 200); // Updated
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($obj->ID, $comment1->ID);
$this->assertEquals($obj->Comment, 'updated');
// by extension
$url = sprintf("/api/v1/RestfulServerTest_Comment/%d.json", $comment1->ID);
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals($response->getStatusCode(), 200); // Updated
$this->assertEquals($response->getHeader('Location'), Controller::join_links(Director::absoluteBaseURL(), $url));
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($obj->ID, $comment1->ID);
$this->assertEquals($obj->Comment, 'updated');
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testPUTwithXML() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
// by mimetype
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$body = '<RestfulServerTest_Comment><Comment>updated</Comment></RestfulServerTest_Comment>';
$response = Director::test($url, null, null, 'PUT', $body, array('Content-Type'=>'text/xml'));
$this->assertEquals($response->getStatusCode(), 200); // Updated
$obj = Convert::xml2array($response->getBody());
$this->assertEquals($obj['ID'], $comment1->ID);
$this->assertEquals($obj['Comment'], 'updated');
// by extension
$url = sprintf("/api/v1/RestfulServerTest_Comment/%d.xml", $comment1->ID);
$body = '<RestfulServerTest_Comment><Comment>updated</Comment></RestfulServerTest_Comment>';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals($response->getStatusCode(), 200); // Updated
$this->assertEquals($response->getHeader('Location'), Controller::join_links(Director::absoluteBaseURL(), $url));
$obj = Convert::xml2array($response->getBody());
$this->assertEquals($obj['ID'], $comment1->ID);
$this->assertEquals($obj['Comment'], 'updated');
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testHTTPAcceptAndContentType() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$headers = array('Accept' => 'application/json');
$response = Director::test($url, null, null, 'GET', null, $headers);
$this->assertEquals($response->getStatusCode(), 200); // Success
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($obj->ID, $comment1->ID);
$this->assertEquals($response->getHeader('Content-Type'), 'application/json');
}
public function testNotFound(){
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$url = "/api/v1/RestfulServerTest_Comment/99";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals($response->getStatusCode(), 404);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testMethodNotAllowed() {
$comment1 = $this->objFromFixture('RestfulServerTest_Comment', 'comment1');
$url = "/api/v1/RestfulServerTest_Comment/" . $comment1->ID;
$response = Director::test($url, null, null, 'UNKNOWNHTTPMETHOD');
$this->assertEquals($response->getStatusCode(), 405);
}
public function testConflictOnExistingResourceWhenUsingPost() {
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating', 'rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$response = Director::test($url, null, null, 'POST');
$this->assertEquals($response->getStatusCode(), 409);
}
public function testUnsupportedMediaType() {
$_SERVER['PHP_AUTH_USER'] = 'user@test.com';
$_SERVER['PHP_AUTH_PW'] = 'user';
$url = "/api/v1/RestfulServerTest_Comment";
$data = "Comment||\/||updated"; // weird format
$headers = array('Content-Type' => 'text/weirdformat');
$response = Director::test($url, null, null, 'POST', $data, $headers);
$this->assertEquals($response->getStatusCode(), 415);
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
}
public function testXMLValueFormatting() {
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating','rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<ID>' . $rating1->ID . '</ID>', $response->getBody());
$this->assertContains('<Rating>' . $rating1->Rating . '</Rating>', $response->getBody());
}
public function testApiAccessFieldRestrictions() {
$author1 = $this->objFromFixture('RestfulServerTest_Author','author1');
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating','rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<ID>', $response->getBody());
$this->assertContains('<Rating>', $response->getBody());
$this->assertContains('<Author', $response->getBody());
$this->assertNotContains('<SecretField>', $response->getBody());
$this->assertNotContains('<SecretRelation>', $response->getBody());
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID . '?add_fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains('<SecretField>', $response->getBody(),
'"add_fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertNotContains('<SecretRelation>', $response->getBody(),
'"add_fields" URL parameter filters out disallowed relations from $api_access'
);
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID . '?fields=SecretField,SecretRelation';
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains('<SecretField>', $response->getBody(),
'"fields" URL parameter filters out disallowed fields from $api_access'
);
$this->assertNotContains('<SecretRelation>', $response->getBody(),
'"fields" URL parameter filters out disallowed relations from $api_access'
);
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . '/Ratings';
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<Rating>', $response->getBody(),
'Relation viewer shows fields allowed through $api_access'
);
$this->assertNotContains('<SecretField>', $response->getBody(),
'Relation viewer on has-many filters out disallowed fields from $api_access'
);
}
public function testApiAccessRelationRestrictionsInline() {
$author1 = $this->objFromFixture('RestfulServerTest_Author','author1');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertNotContains('<RelatedPages', $response->getBody(), 'Restricts many-many with api_access=false');
$this->assertNotContains('<PublishedPages', $response->getBody(), 'Restricts has-many with api_access=false');
}
public function testApiAccessRelationRestrictionsOnEndpoint() {
$author1 = $this->objFromFixture('RestfulServerTest_Author','author1');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . "/ProfilePage";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts has-one with api_access=false');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . "/RelatedPages";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts many-many with api_access=false');
$url = "/api/v1/RestfulServerTest_Author/" . $author1->ID . "/PublishedPages";
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(404, $response->getStatusCode(), 'Restricts has-many with api_access=false');
}
public function testApiAccessWithPUT() {
$rating1 = $this->objFromFixture('RestfulServerTest_AuthorRating','rating1');
$url = "/api/v1/RestfulServerTest_AuthorRating/" . $rating1->ID;
$data = array(
'Rating' => '42',
'WriteProtectedField' => 'haxx0red'
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals($responseArr['Rating'], 42);
$this->assertNotEquals($responseArr['WriteProtectedField'], 'haxx0red');
}
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');
}
}
/**
* Everybody can view comments, logged in members in the "users" group can create comments,
* but only "editors" can edit or delete them.
*
*/
class RestfulServerTest_Comment extends DataObject implements PermissionProvider,TestOnly {
static $api_access = true;
static $db = array(
"Name" => "Varchar(255)",
"Comment" => "Text"
);
static $has_one = array(
'Page' => 'RestfulServerTest_Page',
'Author' => 'RestfulServerTest_Author',
);
public function providePermissions(){
return array(
'EDIT_Comment' => 'Edit Comment Objects',
'CREATE_Comment' => 'Create Comment Objects',
'DELETE_Comment' => 'Delete Comment Objects',
);
}
public function canView($member = null) {
return true;
}
public function canEdit($member = null) {
return Permission::checkMember($member, 'EDIT_Comment');
}
public function canDelete($member = null) {
return Permission::checkMember($member, 'DELETE_Comment');
}
public function canCreate($member = null) {
return Permission::checkMember($member, 'CREATE_Comment');
}
}
class RestfulServerTest_SecretThing extends DataObject implements TestOnly,PermissionProvider{
static $api_access = true;
static $db = array(
"Name" => "Varchar(255)",
);
public function canView($member = null) {
return Permission::checkMember($member, 'VIEW_SecretThing');
}
public function providePermissions(){
return array(
'VIEW_SecretThing' => 'View Secret Things',
);
}
}
class RestfulServerTest_Page extends DataObject implements TestOnly {
static $api_access = false;
static $db = array(
'Title' => 'Text',
'Content' => 'HTMLText',
);
static $has_one = array(
'Author' => 'RestfulServerTest_Author',
);
static $has_many = array(
'TestComments' => 'RestfulServerTest_Comment'
);
static $belongs_many_many = array(
'RelatedAuthors' => 'RestfulServerTest_Author',
);
}
class RestfulServerTest_Author extends DataObject implements TestOnly {
static $api_access = true;
static $db = array(
'Name' => 'Text',
);
static $many_many = array(
'RelatedPages' => 'RestfulServerTest_Page',
'RelatedAuthors' => 'RestfulServerTest_Author',
);
static $has_many = array(
'PublishedPages' => 'RestfulServerTest_Page',
'Ratings' => 'RestfulServerTest_AuthorRating',
);
public function canView($member = null) {
return true;
}
}
class RestfulServerTest_AuthorRating extends DataObject implements TestOnly {
static $api_access = array(
'view' => array(
'Rating',
'WriteProtectedField',
'Author'
),
'edit' => array(
'Rating'
)
);
static $db = array(
'Rating' => 'Int',
'SecretField' => 'Text',
'WriteProtectedField' => 'Text',
);
static $has_one = array(
'Author' => 'RestfulServerTest_Author',
'SecretRelation' => 'RestfulServerTest_Author',
);
public function canView($member = null) {
return true;
}
public function canEdit($member = null) {
return true;
}
public function canCreate($member = null) {
return true;
}
}

View File

@ -0,0 +1,66 @@
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