Merge pull request #53 from silverstripe-terraformers/feature/api-field-mapping

Add field mapping config
This commit is contained in:
Robbie Averill 2018-03-23 09:43:17 +13:00 committed by GitHub
commit 4581fbf479
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 247 additions and 8 deletions

View File

@ -59,6 +59,44 @@ class Article extends DataObject {
} }
``` ```
Example DataObject field mapping, allows aliasing fields so that public requests and responses display different field names:
```php
namespace Vendor\Project;
use SilverStripe\ORM\DataObject;
class Article extends DataObject {
private static $db = [
'Title'=>'Text',
'Published'=>'Boolean'
];
private static $api_access = [
'view' => ['Title', 'Content'],
];
private static $api_field_mapping = [
'customTitle' => 'Title',
];
}
```
Given a dataobject with values:
```yml
ID: 12
Title: Title Value
Content: Content value
```
which when requesting with the url `/api/v1/Vendor-Project-Article/12?fields=customTitle,Content` and `Accept: application/json` the response will look like:
```Javascript
{
"customTitle": "Title Value",
"Content": "Content value"
}
```
Similarly, `PUT` or `POST` requests will have fields transformed from the alias name to the DB field name.
## Supported operations ## Supported operations
- `GET /api/v1/(ClassName)/(ID)` - gets a database record - `GET /api/v1/(ClassName)/(ID)` - gets a database record

View File

@ -373,4 +373,87 @@ abstract class DataFormatter
{ {
user_error('DataFormatter::convertStringToArray not implemented on subclass', E_USER_ERROR); user_error('DataFormatter::convertStringToArray not implemented on subclass', E_USER_ERROR);
} }
/**
* Convert an array of aliased field names to their Dataobject field name
*
* @param string $className
* @param string[] $fields
* @return string[]
*/
public function getRealFields($className, $fields)
{
$apiMapping = $this->getApiMapping($className);
if (is_array($apiMapping) && is_array($fields)) {
$mappedFields = [];
foreach ($fields as $field) {
$mappedFields[] = $this->getMappedKey($apiMapping, $field);
}
return $mappedFields;
}
return $fields;
}
/**
* Get the DataObject field name from its alias
*
* @param string $className
* @param string $field
* @return string
*/
public function getRealFieldName($className, $field)
{
$apiMapping = $this->getApiMapping($className);
return $this->getMappedKey($apiMapping, $field);
}
/**
* Get a DataObject Field's Alias
* defaults to the fieldname
*
* @param string $className
* @param string $field
* @return string
*/
public function getFieldAlias($className, $field)
{
$apiMapping = $this->getApiMapping($className);
$apiMapping = array_flip($apiMapping);
return $this->getMappedKey($apiMapping, $field);
}
/**
* Get the 'api_field_mapping' config value for a class
* or return an empty array
*
* @param string $className
* @return string[]|array
*/
protected function getApiMapping($className)
{
$apiMapping = Config::inst()->get($className, 'api_field_mapping');
if ($apiMapping && is_array($apiMapping)) {
return $apiMapping;
}
return [];
}
/**
* Helper function to get mapped field names
*
* @param array $map
* @param string $key
* @return string
*/
protected function getMappedKey($map, $key)
{
if (is_array($map)) {
if (array_key_exists($key, $map)) {
return $map[$key];
} else {
return $key;
}
}
return $key;
}
} }

View File

@ -89,7 +89,8 @@ class JSONDataFormatter extends DataFormatter
} }
$fieldValue = $obj->obj($fieldName)->forTemplate(); $fieldValue = $obj->obj($fieldName)->forTemplate();
$serobj->$fieldName = $fieldValue; $mappedFieldName = $this->getFieldAlias($className, $fieldName);
$serobj->$mappedFieldName = $fieldValue;
} }
if ($this->relationDepth > 0) { if ($this->relationDepth > 0) {

View File

@ -137,7 +137,8 @@ class XMLDataFormatter extends DataFormatter
} else { } else {
$fieldValue = Convert::raw2xml($fieldValue); $fieldValue = Convert::raw2xml($fieldValue);
} }
$xml .= "<$fieldName>$fieldValue</$fieldName>\n"; $mappedFieldName = $this->getFieldAlias(get_class($obj), $fieldName);
$xml .= "<$mappedFieldName>$fieldValue</$mappedFieldName>\n";
} }
} }

View File

@ -259,7 +259,8 @@ class RestfulServer extends Controller
$this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType()); $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
$rawFields = $this->request->getVar('fields'); $rawFields = $this->request->getVar('fields');
$fields = $rawFields ? explode(',', $rawFields) : null; $realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields));
$fields = $rawFields ? $realFields : null;
if ($obj instanceof SS_List) { if ($obj instanceof SS_List) {
$objs = ArrayList::create($obj->toArray()); $objs = ArrayList::create($obj->toArray());
@ -347,10 +348,12 @@ class RestfulServer extends Controller
// set custom fields // set custom fields
if ($customAddFields = $this->request->getVar('add_fields')) { if ($customAddFields = $this->request->getVar('add_fields')) {
$formatter->setCustomAddFields(explode(',', $customAddFields)); $customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields));
$formatter->setCustomAddFields($customAddFields);
} }
if ($customFields = $this->request->getVar('fields')) { if ($customFields = $this->request->getVar('fields')) {
$formatter->setCustomFields(explode(',', $customFields)); $customFields = $formatter->getRealFields($className, explode(',', $customFields));
$formatter->setCustomFields($customFields);
} }
$formatter->setCustomRelations($this->getAllowedRelations($className)); $formatter->setCustomRelations($this->getAllowedRelations($className));
@ -495,6 +498,13 @@ class RestfulServer extends Controller
return $this->notFound(); return $this->notFound();
} }
$reqFormatter = $this->getRequestDataFormatter($className);
if (!$reqFormatter) {
return $this->unsupportedMediaType();
}
$relation = $reqFormatter->getRealFieldName($className, $relation);
if (!$obj->hasMethod($relation)) { if (!$obj->hasMethod($relation)) {
return $this->notFound(); return $this->notFound();
} }
@ -573,16 +583,23 @@ class RestfulServer extends Controller
} }
if (!empty($body)) { if (!empty($body)) {
$data = $formatter->convertStringToArray($body); $rawdata = $formatter->convertStringToArray($body);
} else { } else {
// assume application/x-www-form-urlencoded which is automatically parsed by PHP // assume application/x-www-form-urlencoded which is automatically parsed by PHP
$data = $this->request->postVars(); $rawdata = $this->request->postVars();
}
$className = $this->unsanitiseClassName($this->request->param('ClassName'));
// update any aliased field names
$data = [];
foreach ($rawdata as $key => $value) {
$newkey = $formatter->getRealFieldName($className, $key);
$data[$newkey] = $value;
} }
// @todo Disallow editing of certain keys in database // @todo Disallow editing of certain keys in database
$data = array_diff_key($data, ['ID', 'Created']); $data = array_diff_key($data, ['ID', 'Created']);
$className = $this->unsanitiseClassName($this->request->param('ClassName'));
$apiAccess = singleton($className)->config()->api_access; $apiAccess = singleton($className)->config()->api_access;
if (is_array($apiAccess) && isset($apiAccess['edit'])) { if (is_array($apiAccess) && isset($apiAccess['edit'])) {
$data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit'])); $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));

View File

@ -17,6 +17,7 @@ use SilverStripe\ORM\DataObject;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter; use SilverStripe\RestfulServer\DataFormatter\JSONDataFormatter;
use Page; use Page;
use SilverStripe\Core\Config\Config;
/** /**
* *
@ -113,6 +114,18 @@ class RestfulServerTest extends SapphireTest
unset($_SERVER['PHP_AUTH_PW']); unset($_SERVER['PHP_AUTH_PW']);
} }
public function testGETWithFieldAlias()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals(3, $responseArr['rate']);
}
public function testAuthenticatedPUT() public function testAuthenticatedPUT()
{ {
$comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1'); $comment1 = $this->objFromFixture(RestfulServerTestComment::class, 'comment1');
@ -160,6 +173,28 @@ class RestfulServerTest extends SapphireTest
$this->assertContains($rating2->ID, $ratingIDs); $this->assertContains($rating2->ID, $ratingIDs);
} }
public function testGETRelationshipsWithAlias()
{
// Alias do not currently work with Relationships
Config::inst()->set(RestfulServerTestAuthor::class, 'api_field_mapping', ['stars' => 'Ratings']);
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
// @todo should be set up by fixtures, doesn't work for some reason...
$author1->Ratings()->add($rating1);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthor::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $author1->ID . '?add_fields=stars';
$response = Director::test($url, null, null, 'GET');
$this->assertEquals(200, $response->getStatusCode());
$responseArr = Convert::xml2array($response->getBody());
$xmlTagSafeClassName = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$this->assertTrue(array_key_exists('Ratings', $responseArr));
$this->assertFalse(array_key_exists('stars', $responseArr));
}
public function testGETManyManyRelationshipsXML() public function testGETManyManyRelationshipsXML()
{ {
// author4 has related authors author2 and author3 // author4 has related authors author2 and author3
@ -395,6 +430,17 @@ class RestfulServerTest extends SapphireTest
$this->assertContains('<Rating>' . $rating1->Rating . '</Rating>', $response->getBody()); $this->assertContains('<Rating>' . $rating1->Rating . '</Rating>', $response->getBody());
} }
public function testXMLValueFormattingWithFieldAlias()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
$response = Director::test($url, null, null, 'GET');
$this->assertContains('<rate>' . $rating1->Rating . '</rate>', $response->getBody());
}
public function testApiAccessFieldRestrictions() public function testApiAccessFieldRestrictions()
{ {
$author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1'); $author1 = $this->objFromFixture(RestfulServerTestAuthor::class, 'author1');
@ -500,6 +546,23 @@ class RestfulServerTest extends SapphireTest
$this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']); $this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']);
} }
public function testFieldAliasWithPUT()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$rating1 = $this->objFromFixture(RestfulServerTestAuthorRating::class, 'rating1');
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/" . $rating1->ID;
// Test input with original fieldname
$data = array(
'Rating' => '42',
);
$response = Director::test($url, $data, null, 'PUT');
// Assumption: XML is default output
$responseArr = Convert::xml2array($response->getBody());
// should output with aliased name
$this->assertEquals(42, $responseArr['rate']);
}
public function testJSONDataFormatter() public function testJSONDataFormatter()
{ {
$formatter = new JSONDataFormatter(); $formatter = new JSONDataFormatter();
@ -527,6 +590,29 @@ class RestfulServerTest extends SapphireTest
); );
} }
public function testJSONDataFormatterWithFieldAlias()
{
Config::inst()->set(Member::class, 'api_field_mapping', ['MyName' => 'FirstName']);
$formatter = new JSONDataFormatter();
$editor = $this->objFromFixture(Member::class, 'editor');
$user = $this->objFromFixture(Member::class, 'user');
// The DataFormatter performs canView calls
// these are `Member`s so we need to be ADMIN types
$this->logInWithPermission('ADMIN');
$set = Member::get()
->filter('ID', [$editor->ID, $user->ID])
->sort('"Email" ASC'); // for sorting for postgres
$this->assertEquals(
'{"totalSize":null,"items":[{"MyName":"Editor","Email":"editor@test.com"},' .
'{"MyName":"User","Email":"user@test.com"}]}',
$formatter->convertDataObjectSet($set, ["FirstName", "Email"]),
"Correct JSON formatting with field alias"
);
}
public function testApiAccessWithPOST() public function testApiAccessWithPOST()
{ {
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class); $urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
@ -542,6 +628,19 @@ class RestfulServerTest extends SapphireTest
$this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']); $this->assertNotEquals('haxx0red', $responseArr['WriteProtectedField']);
} }
public function testFieldAliasWithPOST()
{
Config::inst()->set(RestfulServerTestAuthorRating::class, 'api_field_mapping', ['rate' => 'Rating']);
$urlSafeClassname = $this->urlSafeClassname(RestfulServerTestAuthorRating::class);
$url = "{$this->baseURI}/api/v1/$urlSafeClassname/";
$data = [
'rate' => '42',
];
$response = Director::test($url, $data, null, 'POST');
$responseArr = Convert::xml2array($response->getBody());
$this->assertEquals(42, $responseArr['rate']);
}
public function testCanViewRespectedInList() public function testCanViewRespectedInList()
{ {
// Default content type // Default content type