This commit is contained in:
Russ Michell 2018-06-05 18:12:46 +00:00 committed by GitHub
commit 8dcc0c4ffb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 367 additions and 31 deletions

View File

@ -28,7 +28,11 @@
"SilverStripe\\RestfulServer\\Tests\\": "tests"
}
},
"extra": [],
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"prefer-stable": true,
"minimum-stability": "dev"
}

View File

@ -2,12 +2,14 @@
namespace SilverStripe\RestfulServer\DataFormatter;
use SilverStripe\RestfulServer\RestfulServer;
use SilverStripe\View\ArrayData;
use SilverStripe\Core\Convert;
use SilverStripe\RestfulServer\DataFormatter;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\Control\Director;
use SilverStripe\ORM\SS_List;
use SilverStripe\ORM\FieldType;
/**
* Formats a DataObject's member fields into a JSON string
@ -88,7 +90,7 @@ class JSONDataFormatter extends DataFormatter
continue;
}
$fieldValue = $obj->obj($fieldName)->forTemplate();
$fieldValue = self::cast($obj->obj($fieldName));
$mappedFieldName = $this->getFieldAlias($className, $fieldName);
$serobj->$mappedFieldName = $fieldValue;
}
@ -119,11 +121,13 @@ class JSONDataFormatter extends DataFormatter
$serobj->$relName = ArrayData::array_to_object(array(
"className" => $relClass,
"href" => "$href.json",
"id" => $obj->$fieldName
"id" => self::cast($obj->obj($fieldName))
));
}
foreach ($obj->hasMany() + $obj->manyMany() as $relName => $relClass) {
$relClass = RestfulServer::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass);
$relClass = array_shift($parts);
@ -193,4 +197,19 @@ class JSONDataFormatter extends DataFormatter
{
return Convert::json2array($strData);
}
public static function cast(FieldType\DBField $dbfield)
{
switch (true) {
case $dbfield instanceof FieldType\DBInt:
return (int)$dbfield->RAW();
case $dbfield instanceof FieldType\DBFloat:
return (float)$dbfield->RAW();
case $dbfield instanceof FieldType\DBBoolean:
return (bool)$dbfield->RAW();
case is_null($dbfield->RAW()):
return null;
}
return $dbfield->RAW();
}
}

View File

@ -10,6 +10,7 @@ use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\Control\Director;
use SilverStripe\ORM\SS_List;
use SilverStripe\RestfulServer\RestfulServer;
/**
* Formats a DataObject's member fields into an XML string
@ -196,6 +197,8 @@ class XMLDataFormatter extends DataFormatter
}
foreach ($obj->manyMany() as $relName => $relClass) {
$relClass = RestfulServer::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass);
$relClass = array_shift($parts);

View File

@ -15,6 +15,7 @@ use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Core\Injector\Injector;
/**
* Generic RESTful server, which handles webservice access to arbitrary DataObjects.
@ -41,6 +42,10 @@ use SilverStripe\CMS\Model\SiteTree;
*/
class RestfulServer extends Controller
{
/**
* @config
* @var array
*/
private static $url_handlers = array(
'$ClassName!/$ID/$Relation' => 'handleAction',
'' => 'notFound'
@ -62,10 +67,24 @@ class RestfulServer extends Controller
* If no extension is given in the request, resolve to this extension
* (and subsequently the {@link self::$default_mimetype}.
*
* @config
* @var string
*/
private static $default_extension = "xml";
/**
* Whether or not to send an additional "Location" header for POST requests
* to satisfy HTTP 1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
*
* Note: With this enabled (the default), no POST request for resource creation
* will return an HTTP 201. Because of the addition of the "Location" header,
* all responses become a straight HTTP 200.
*
* @config
* @var boolean
*/
private static $location_header_on_create = true;
/**
* If no extension is given, resolve the request to this mimetype.
*
@ -122,6 +141,32 @@ class RestfulServer extends Controller
return str_replace('-', '\\', $className);
}
/**
* Parse many many relation class (works with through array syntax)
*
* @param string|array $class
* @return string|array
*/
public static function parseRelationClass($class)
{
// detect many many through syntax
if (is_array($class)
&& array_key_exists('through', $class)
&& array_key_exists('to', $class)
) {
$toRelation = $class['to'];
$hasOne = Config::inst()->get($class['through'], 'has_one');
if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne)) {
return $class;
}
return $hasOne[$toRelation];
}
return $class;
}
/**
* This handler acts as the switchboard for the controller.
* Since no $Action url-param is set, all requests are sent here.
@ -154,21 +199,25 @@ class RestfulServer extends Controller
// authenticate through HTTP BasicAuth
$this->member = $this->authenticate();
// handle different HTTP verbs
if ($this->request->isGET() || $this->request->isHEAD()) {
return $this->getHandler($className, $id, $relation);
}
try {
// handle different HTTP verbs
if ($this->request->isGET() || $this->request->isHEAD()) {
return $this->getHandler($className, $id, $relation);
}
if ($this->request->isPOST()) {
return $this->postHandler($className, $id, $relation);
}
if ($this->request->isPOST()) {
return $this->postHandler($className, $id, $relation);
}
if ($this->request->isPUT()) {
return $this->putHandler($className, $id, $relation);
}
if ($this->request->isPUT()) {
return $this->putHandler($className, $id, $relation);
}
if ($this->request->isDELETE()) {
return $this->deleteHandler($className, $id, $relation);
if ($this->request->isDELETE()) {
return $this->deleteHandler($className, $id, $relation);
}
} catch (\Exception $e) {
return $this->exceptionThrown($this->getRequestDataFormatter($className), $e);
}
// if no HTTP verb matches, return error
@ -459,7 +508,7 @@ class RestfulServer extends Controller
return $obj;
}
$this->getResponse()->setStatusCode(200); // Success
$this->getResponse()->setStatusCode(202); // Accepted
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
// Append the default extension for the output format to the Location header
@ -523,7 +572,8 @@ class RestfulServer extends Controller
if (!singleton($className)->canCreate($this->getMember())) {
return $this->permissionFailure();
}
$obj = new $className();
$obj = Injector::inst()->create($className);
$reqFormatter = $this->getRequestDataFormatter($className);
if (!$reqFormatter) {
@ -554,10 +604,15 @@ class RestfulServer extends Controller
$type = ".{$types[0]}";
}
$urlSafeClassName = $this->sanitiseClassName(get_class($obj));
$apiBase = $this->config()->api_base;
$objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
$this->getResponse()->addHeader('Location', $objHref);
// Deviate slightly from the spec: Helps datamodel API access restrict
// to consulting just canCreate(), not canView() as a result of the additional
// "Location" header.
if ($this->config()->get('location_header_on_create')) {
$urlSafeClassName = $this->sanitiseClassName(get_class($obj));
$apiBase = $this->config()->api_base;
$objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
$this->getResponse()->addHeader('Location', $objHref);
}
return $responseFormatter->convertDataObject($obj);
}
@ -681,7 +736,11 @@ class RestfulServer extends Controller
$this->getResponse()->setStatusCode(401);
$this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "You don't have access to this item through the API.";
$reponse = "You don't have access to this item through the API.";
$this->extend(__FUNCTION__, $reponse);
return $reponse;
}
/**
@ -692,7 +751,11 @@ class RestfulServer extends Controller
// return a 404
$this->getResponse()->setStatusCode(404);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "That object wasn't found";
$reponse = "That object wasn't found";
$this->extend(__FUNCTION__, $reponse);
return $reponse;
}
/**
@ -702,7 +765,11 @@ class RestfulServer extends Controller
{
$this->getResponse()->setStatusCode(405);
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "Method Not Allowed";
$reponse = "Method Not Allowed";
$this->extend(__FUNCTION__, $reponse);
return $reponse;
}
/**
@ -712,7 +779,11 @@ class RestfulServer extends Controller
{
$this->response->setStatusCode(415); // Unsupported Media Type
$this->getResponse()->addHeader('Content-Type', 'text/plain');
return "Unsupported Media Type";
$reponse = "Unsupported Media Type";
$this->extend(__FUNCTION__, $reponse);
return $reponse;
}
/**
@ -729,6 +800,28 @@ class RestfulServer extends Controller
'messages' => $result->getMessages(),
];
$this->extend(__FUNCTION__, $response, $result);
return $responseFormatter->convertArray($response);
}
/**
* @param DataFormatter $responseFormatter
* @param \Exception $e
* @return string
*/
protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
{
$this->getResponse()->setStatusCode(500);
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$response = [
'type' => get_class($e),
'message' => $e->getMessage(),
];
$this->extend(__FUNCTION__, $response, $e);
return $responseFormatter->convertArray($response);
}
@ -760,6 +853,8 @@ class RestfulServer extends Controller
$relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
if ($relations) {
foreach ($relations as $relName => $relClass) {
$relClass = static::parseRelationClass($relClass);
//remove dot notation from relation names
$parts = explode('.', $relClass);
$relClass = array_shift($parts);

View File

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

View File

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

View File

@ -2,7 +2,9 @@
namespace SilverStripe\RestfulServer\Tests;
use SilverStripe\RestfulServer\RestfulServer;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestComment;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestExceptionThrown;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestSecretThing;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestPage;
use SilverStripe\RestfulServer\Tests\Stubs\RestfulServerTestAuthor;
@ -38,6 +40,8 @@ class RestfulServerTest extends SapphireTest
RestfulServerTestPage::class,
RestfulServerTestAuthor::class,
RestfulServerTestAuthorRating::class,
RestfulServerTestValidationFailure::class,
RestfulServerTestExceptionThrown::class,
];
protected function urlSafeClassname($classname)
@ -140,7 +144,7 @@ class RestfulServerTest extends SapphireTest
$_SERVER['PHP_AUTH_USER'] = 'editor@test.com';
$_SERVER['PHP_AUTH_PW'] = 'editor';
$response = Director::test($url, $data, null, 'PUT');
$this->assertEquals(200, $response->getStatusCode()); // Success
$this->assertEquals(202, $response->getStatusCode()); // Accepted
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
@ -233,7 +237,7 @@ class RestfulServerTest extends SapphireTest
'Content-Type' => 'application/x-www-form-urlencoded'
);
$response = Director::test($url, null, null, 'PUT', $body, $headers);
$this->assertEquals(200, $response->getStatusCode()); // Success
$this->assertEquals(202, $response->getStatusCode()); // Accepted
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals($comment1->ID, $responseArr['ID']);
@ -302,7 +306,7 @@ class RestfulServerTest extends SapphireTest
'Content-Type'=>'application/json',
'Accept' => 'application/json'
));
$this->assertEquals(200, $response->getStatusCode()); // Updated
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($comment1->ID, $obj->ID);
$this->assertEquals('updated', $obj->Comment);
@ -312,7 +316,7 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/{$comment1->ID}.json";
$body = '{"Comment":"updated"}';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals(200, $response->getStatusCode()); // Updated
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$this->assertEquals($url, $response->getHeader('Location'));
$obj = Convert::json2obj($response->getBody());
$this->assertEquals($comment1->ID, $obj->ID);
@ -334,7 +338,7 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $comment1->ID;
$body = '<RestfulServerTestComment><Comment>updated</Comment></RestfulServerTestComment>';
$response = Director::test($url, null, null, 'PUT', $body, array('Content-Type'=>'text/xml'));
$this->assertEquals(200, $response->getStatusCode()); // Updated
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$obj = Convert::xml2array($response->getBody());
$this->assertEquals($comment1->ID, $obj['ID']);
$this->assertEquals('updated', $obj['Comment']);
@ -344,7 +348,7 @@ class RestfulServerTest extends SapphireTest
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/{$comment1->ID}.xml";
$body = '<RestfulServerTestComment><Comment>updated</Comment></RestfulServerTestComment>';
$response = Director::test($url, null, null, 'PUT', $body);
$this->assertEquals(200, $response->getStatusCode()); // Updated
$this->assertEquals(202, $response->getStatusCode()); // Accepted
$this->assertEquals($url, $response->getHeader('Location'));
$obj = Convert::xml2array($response->getBody());
$this->assertEquals($comment1->ID, $obj['ID']);
@ -685,4 +689,30 @@ class RestfulServerTest extends SapphireTest
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals('SilverStripe\\ORM\\ValidationException', $responseArr['type']);
}
public function testExceptionThrownWithPOST()
{
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestExceptionThrown::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$data = [
'Content' => 'Test',
];
$response = Director::test($url, $data, null, 'POST');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals(\Exception::class, $responseArr['type']);
}
public function testParseClassName()
{
$manyMany = RestfulServerTestAuthor::config()->get('many_many');
// simple syntax (many many standard)
$className = RestfulServer::parseRelationClass($manyMany['RelatedPages']);
$this->assertEquals(RestfulServerTestPage::class, $className);
// array syntax (many many through)
$className = RestfulServer::parseRelationClass($manyMany['SortedPages']);
$this->assertEquals(RestfulServerTestPage::class, $className);
}
}

View File

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

View File

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

View File

@ -18,11 +18,17 @@ class RestfulServerTestAuthor extends DataObject implements TestOnly
private static $many_many = array(
'RelatedPages' => RestfulServerTestPage::class,
'RelatedAuthors' => RestfulServerTestAuthor::class,
'SortedPages' => [
'through' => AuthorSortedPageRelation::class,
'from' => 'Parent',
'to' => 'SortedPage',
],
);
private static $has_many = array(
'PublishedPages' => RestfulServerTestPage::class,
'Ratings' => RestfulServerTestAuthorRating::class,
'SortedPagesRelation' => AuthorSortedPageRelation::class . '.Parent',
);
public function canView($member = null)

View File

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