commit 90bfa5eac62b98accbf6b01fac4f9b2be2fa3c6d Author: Ingo Schommer Date: Mon Jun 4 10:14:02 2012 +0200 MINOR Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b84e10f --- /dev/null +++ b/LICENSE @@ -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 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd66c1e --- /dev/null +++ b/README.md @@ -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=`: Limit the result set +- `&relationdepth=`: Displays links to existing has-one and has-many relationships to a certain depth (Default: 1) +- `&fields=`: Comma-separated list of fields on the output object (defaults to all database-columns). + Handy to limit output for bandwidth and performance reasons. +- `&sort=&dir=` +- `&add_fields=`: 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. \ No newline at end of file diff --git a/_config.php b/_config.php new file mode 100644 index 0000000..e69de29 diff --git a/code/RestfulServer.php b/code/RestfulServer.php new file mode 100644 index 0000000..3dba5ff --- /dev/null +++ b/code/RestfulServer.php @@ -0,0 +1,589 @@ + '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: + * + * + * Value + * ... + * + * ... + * + * + * + * + * ... + * + * + * + * + * + * + * 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// + $obj = $this->getObjectQuery($className, $id, $params)->First(); + if(!$obj) return $this->notFound(); + if(!$obj->canView()) return $this->permissionFailure(); + + // Format: /api/v1/// + 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/ + $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// + * + * @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); + } +} diff --git a/tests/RestfulServerTest.php b/tests/RestfulServerTest.php new file mode 100644 index 0000000..7980a18 --- /dev/null +++ b/tests/RestfulServerTest.php @@ -0,0 +1,567 @@ +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 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'); + } + +} + +/** + * 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; + } +} + diff --git a/tests/RestfulServerTest.yml b/tests/RestfulServerTest.yml new file mode 100644 index 0000000..bb12d62 --- /dev/null +++ b/tests/RestfulServerTest.yml @@ -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 \ No newline at end of file