Version: current

      1 <?php
      2 /**
      3  * Sapphire's generic RESTful server.
      4  *
      5  * This class gives your application a RESTful API for free.  All you have to do is define static $api_access = true on
      6  * the appropriate DataObjects.  You will need to ensure that all of your data manipulation and security is defined in
      7  * your model layer (ie, the DataObject classes) and not in your Controllers.  This is the recommended design for Sapphire
      8  * applications.
      9  *
     10  * Enabling restful access on a model will also enable a SOAP API, see {@link SOAPModelAccess}.
     11  *
     12  * Example DataObject with simple api access, giving full access to all object properties and relations,
     13  * unless explicitly controlled through model permissions.
     14  * <code>
     15  * class Article extends DataObject {
     16  *  static $db = array('Title'=>'Text','Published'=>'Boolean');
     17  *  static $api_access = true;
     18  * }
     19  * </code>
     20  *
     21  * * Example DataObject with advanced api access, limiting viewing and editing to Title attribute only:
     22  * <code>
     23  * class Article extends DataObject {
     24  *  static $db = array('Title'=>'Text','Published'=>'Boolean');
     25  *  static $api_access = array(
     26  *      'view' => array('Title'),
     27  *      'edit' => array('Title'),
     28  *  );
     29  * }
     30  * </code>
     31  *
     32  * Supported operations:
     33  *  - GET /api/v1/(ClassName)/(ID) - gets a database record
     34  *  - GET /api/v1/(ClassName)/(ID)/(Relation) - get all of the records linked to this database record by the given reatlion
     35  *  - GET /api/v1/(ClassName)?(Field)=(Val)&(Field)=(Val) - searches for matching database records
     36  *  - POST /api/v1/(ClassName) - create a new database record
     37  *  - PUT /api/v1/(ClassName)/(ID) - updates a database record
     38  *  - PUT /api/v1/(ClassName)/(ID)/(Relation) - updates a relation, replacing the existing record(s) (NOT IMPLEMENTED YET)
     39  *  - POST /api/v1/(ClassName)/(ID)/(Relation) - updates a relation, appending to the existing record(s) (NOT IMPLEMENTED YET)
     40  *
     41  *  - DELETE /api/v1/(ClassName)/(ID) - deletes a database record (NOT IMPLEMENTED YET)
     42  *  - 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)
     43  *
     44  *  - POST /api/v1/(ClassName)/(ID)/(MethodName) - executes a method on the given object (e.g, publish)
     45  *
     46  * You can trigger searches based on the fields specified on {@link DataObject::searchable_fields} and passed
     47  * through {@link DataObject::getDefaultSearchContext()}. Just add a key-value pair with the search-term
     48  * to the url, e.g. /api/v1/(ClassName)/?Title=mytitle.
     49  *
     50  * Other url-modifiers:
     51  * - &limit=<numeric>: Limit the result set
     52  * - &relationdepth=<numeric>: Displays links to existing has-one and has-many relationships to a certain depth (Default: 1)
     53  * - &fields=<string>: Comma-separated list of fields on the output object (defaults to all database-columns).
     54  *   Handy to limit output for bandwidth and performance reasons.
     55  * - &sort=<myfield>&dir=<asc|desc>
     56  * - &add_fields=<string>: Comma-separated list of additional fields, for example dynamic getters.
     57  *
     58  * Access control is implemented through the usual Member system with Basicauth authentication only.
     59  * By default, you have to bear the ADMIN permission to retrieve or send any data.
     60  *
     61  * You should override the following built-in methods to customize permission control on a
     62  * class- and object-level:
     63  * - {@link DataObject::canView()}
     64  * - {@link DataObject::canEdit()}
     65  * - {@link DataObject::canDelete()}
     66  * - {@link DataObject::canCreate()}
     67  * See {@link DataObject} documentation for further details.
     68  *
     69  * You can specify the character-encoding for any input on the HTTP Content-Type.
     70  * At the moment, only UTF-8 is supported. All output is made in UTF-8 regardless of Accept headers.
     71  *
     72  * @todo Finish RestfulServer_Item and RestfulServer_List implementation and re-enable $url_handlers
     73  * @todo Implement PUT/POST/DELETE for relations
     74  * @todo Access-Control for relations (you might be allowed to view Members and Groups, but not their relation with each other)
     75  * @todo Make SearchContext specification customizeable for each class
     76  * @todo Allow for range-searches (e.g. on Created column)
     77  * @todo Allow other authentication methods (currently only HTTP BasicAuth)
     78  * @todo Filter relation listings by $api_access and canView() permissions
     79  * @todo Exclude relations when "fields" are specified through URL (they should be explicitly requested in this case)
     80  * @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in SiteTree/Versioned/Hierarchy
     81  * @todo URL parameter namespacing for search-fields, limit, fields, add_fields (might all be valid dataobject properties)
     82  *       e.g. you wouldn't be able to search for a "limit" property on your subclass as its overlayed with the search logic
     83  * @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
     84  * @todo Access to decoratable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
     85  * @todo Respect $api_access array notation in search contexts
     86  *
     87  * @package sapphire
     88  * @subpackage api
     89  */
     90 class RestfulServer extends Controller {
     91     static $url_handlers = array(
     92         '$ClassName/$ID/$Relation' => 'handleAction'
     93         #'$ClassName/#ID' => 'handleItem',
     94         #'$ClassName' => 'handleList',
     95     );
     96
     97     protected static $api_base = "api/v1/";
     98
     99     /**
    100      * If no extension is given in the request, resolve to this extension
    101      * (and subsequently the {@link self::$default_mimetype}.
    102      *
    103      * @var string
    104      */
    105     public static $default_extension = "xml";
    106
    107     /**
    108      * If no extension is given, resolve the request to this mimetype.
    109      *
    110      * @var string
    111      */
    112     protected static $default_mimetype = "text/xml";
    113
    114     /**
    115      * @uses authenticate()
    116      * @var Member
    117      */
    118     protected $member;
    119
    120     /*
    121     function handleItem($request) {
    122         return new RestfulServer_Item(DataObject::get_by_id($request->param("ClassName"), $request->param("ID")));
    123     }
    124
    125     function handleList($request) {
    126         return new RestfulServer_List(DataObject::get($request->param("ClassName"),""));
    127     }
    128     */
    129
    130     /**
    131      * This handler acts as the switchboard for the controller.
    132      * Since no $Action url-param is set, all requests are sent here.
    133      */
    134     function index() {
    135         if(!isset($this->urlParams['ClassName'])) return $this->notFound();
    136         $className = $this->urlParams['ClassName'];
    137         $id = (isset($this->urlParams['ID'])) ? $this->urlParams['ID'] : null;
    138         $relation = (isset($this->urlParams['Relation'])) ? $this->urlParams['Relation'] : null;
    139
    140         // Check input formats
    141         if(!class_exists($className)) return $this->notFound();
    142         if($id && !is_numeric($id)) return $this->notFound();
    143         if($relation && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)) return $this->notFound();
    144
    145         // if api access is disabled, don't proceed
    146         $apiAccess = singleton($className)->stat('api_access');
    147         if(!$apiAccess) return $this->permissionFailure();
    148
    149         // authenticate through HTTP BasicAuth
    150         $this->member = $this->authenticate();
    151
    152         // handle different HTTP verbs
    153         if($this->request->isGET() || $this->request->isHEAD()) return $this->getHandler($className, $id, $relation);
    154         if($this->request->isPOST()) return $this->postHandler($className, $id, $relation);
    155         if($this->request->isPUT()) return $this->putHandler($className, $id, $relation);
    156         if($this->request->isDELETE()) return $this->deleteHandler($className, $id, $relation);
    157
    158         // if no HTTP verb matches, return error
    159         return $this->methodNotAllowed();
    160     }
    161
    162     /**
    163      * Handler for object read.
    164      *
    165      * The data object will be returned in the following format:
    166      *
    167      * <ClassName>
    168      *   <FieldName>Value</FieldName>
    169      *   ...
    170      *   <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
    171      *   ...
    172      *   <HasManyRelName>
    173      *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
    174      *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
    175      *   </HasManyRelName>
    176      *   ...
    177      *   <ManyManyRelName>
    178      *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
    179      *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
    180      *   </ManyManyRelName>
    181      * </ClassName>
    182      *
    183      * Access is controlled by two variables:
    184      *
    185      *   - static $api_access must be set. This enables the API on a class by class basis
    186      *   - $obj->canView() must return true. This lets you implement record-level security
    187      *
    188      * @todo Access checking
    189      *
    190      * @param String $className
    191      * @param Int $id
    192      * @param String $relation
    193      * @return String The serialized representation of the requested object(s) - usually XML or JSON.
    194      */
    195     protected function getHandler($className, $id, $relationName) {
    196         $sort = array(
    197             'sort' => $this->request->getVar('sort'),
    198             'dir' => $this->request->getVar('dir')
    199         );
    200         $limit = array(
    201             'start' => $this->request->getVar('start'),
    202             'limit' => $this->request->getVar('limit')
    203         );
    204
    205         $params = $this->request->getVars();
    206
    207         $responseFormatter = $this->getResponseDataFormatter();
    208         if(!$responseFormatter) return $this->unsupportedMediaType();
    209
    210         // $obj can be either a DataObject or a DataObjectSet,
    211         // depending on the request
    212         if($id) {
    213             // Format: /api/v1/<MyClass>/<ID>
    214             $query = $this->getObjectQuery($className, $id, $params);
    215             $obj = singleton($className)->buildDataObjectSet($query->execute());
    216             if(!$obj) return $this->notFound();
    217             $obj = $obj->First();
    218             if(!$obj->canView()) return $this->permissionFailure();
    219
    220             // Format: /api/v1/<MyClass>/<ID>/<Relation>
    221             if($relationName) {
    222                 $query = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
    223                 if($query === false) return $this->notFound();
    224                 $obj = singleton($className)->buildDataObjectSet($query->execute());
    225             }
    226
    227         } else {
    228             // Format: /api/v1/<MyClass>
    229             $query = $this->getObjectsQuery($className, $params, $sort, $limit);
    230             $obj = singleton($className)->buildDataObjectSet($query->execute());
    231
    232             // show empty serialized result when no records are present
    233             if(!$obj) $obj = new DataObjectSet();
    234         }
    235
    236         $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
    237
    238         $rawFields = $this->request->getVar('fields');
    239         $fields = $rawFields ? explode(',', $rawFields) : null;
    240
    241         if($obj instanceof DataObjectSet) {
    242             $responseFormatter->setTotalSize($query->unlimitedRowCount());
    243             return $responseFormatter->convertDataObjectSet($obj, $fields);
    244         } else if(!$obj) {
    245             $responseFormatter->setTotalSize(0);
    246             return $responseFormatter->convertDataObjectSet(new DataObjectSet(), $fields);
    247         } else {
    248             return $responseFormatter->convertDataObject($obj, $fields);
    249         }
    250     }
    251
    252     /**
    253      * Uses the default {@link SearchContext} specified through
    254      * {@link DataObject::getDefaultSearchContext()} to augument
    255      * an existing query object (mostly a component query from {@link DataObject})
    256      * with search clauses.
    257      *
    258      * @todo Allow specifying of different searchcontext getters on model-by-model basis
    259      *
    260      * @param string $className
    261      * @param array $params
    262      * @return DataObjectSet
    263      */
    264     protected function getSearchQuery($className, $params = null, $sort = null, $limit = null, $existingQuery = null) {
    265         if(singleton($className)->hasMethod('getRestfulSearchContext')) {
    266             $searchContext = singleton($className)->{'getRestfulSearchContext'}();
    267         } else {
    268             $searchContext = singleton($className)->getDefaultSearchContext();
    269         }
    270         $query = $searchContext->getQuery($params, $sort, $limit, $existingQuery);
    271
    272         return $query;
    273     }
    274
    275     /**
    276      * Returns a dataformatter instance based on the request
    277      * extension or mimetype. Falls back to {@link self::$default_extension}.
    278      *
    279      * @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
    280      * @return DataFormatter
    281      */
    282     protected function getDataFormatter($includeAcceptHeader = false) {
    283         $extension = $this->request->getExtension();
    284         $contentTypeWithEncoding = $this->request->getHeader('Content-Type');
    285         preg_match('/([^;]*)/',$contentTypeWithEncoding, $contentTypeMatches);
    286         $contentType = $contentTypeMatches[0];
    287         $accept = $this->request->getHeader('Accept');
    288         $mimetypes = $this->request->getAcceptMimetypes();
    289
    290         // get formatter
    291         if(!empty($extension)) {
    292             $formatter = DataFormatter::for_extension($extension);
    293         }elseif($includeAcceptHeader && !empty($accept) && $accept != '*/*') {
    294             $formatter = DataFormatter::for_mimetypes($mimetypes);
    295             if(!$formatter) $formatter = DataFormatter::for_extension(self::$default_extension);
    296         } elseif(!empty($contentType)) {
    297             $formatter = DataFormatter::for_mimetype($contentType);
    298         } else {
    299             $formatter = DataFormatter::for_extension(self::$default_extension);
    300         }
    301
    302         if(!$formatter) return false;
    303
    304         // set custom fields
    305         if($customAddFields = $this->request->getVar('add_fields')) $formatter->setCustomAddFields(explode(',',$customAddFields));
    306         if($customFields = $this->request->getVar('fields')) $formatter->setCustomFields(explode(',',$customFields));
    307         $formatter->setCustomRelations($this->getAllowedRelations($this->urlParams['ClassName']));
    308
    309         $apiAccess = singleton($this->urlParams['ClassName'])->stat('api_access');
    310         if(is_array($apiAccess)) {
    311             $formatter->setCustomAddFields(array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view']));
    312             if($formatter->getCustomFields()) {
    313                 $formatter->setCustomFields(array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view']));
    314             } else {
    315                 $formatter->setCustomFields((array)$apiAccess['view']);
    316             }
    317             if($formatter->getCustomRelations()) {
    318                 $formatter->setCustomRelations(array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view']));
    319             } else {
    320                 $formatter->setCustomRelations((array)$apiAccess['view']);
    321             }
    322
    323         }
    324
    325         // set relation depth
    326         $relationDepth = $this->request->getVar('relationdepth');
    327         if(is_numeric($relationDepth)) $formatter->relationDepth = (int)$relationDepth;
    328
    329         return $formatter;
    330     }
    331
    332     protected function getRequestDataFormatter() {
    333         return $this->getDataFormatter(false);
    334     }
    335
    336     protected function getResponseDataFormatter() {
    337         return $this->getDataFormatter(true);
    338     }
    339
    340     /**
    341      * Handler for object delete
    342      */
    343     protected function deleteHandler($className, $id) {
    344         $obj = DataObject::get_by_id($className, $id);
    345         if(!$obj) return $this->notFound();
    346         if(!$obj->canDelete()) return $this->permissionFailure();
    347
    348         $obj->delete();
    349
    350         $this->getResponse()->setStatusCode(204); // No Content
    351         return true;
    352     }
    353
    354     /**
    355      * Handler for object write
    356      */
    357     protected function putHandler($className, $id) {
    358         $obj = DataObject::get_by_id($className, $id);
    359         if(!$obj) return $this->notFound();
    360         if(!$obj->canEdit()) return $this->permissionFailure();
    361
    362         $reqFormatter = $this->getRequestDataFormatter();
    363         if(!$reqFormatter) return $this->unsupportedMediaType();
    364
    365         $responseFormatter = $this->getResponseDataFormatter();
    366         if(!$responseFormatter) return $this->unsupportedMediaType();
    367
    368         $obj = $this->updateDataObject($obj, $reqFormatter);
    369
    370         $this->getResponse()->setStatusCode(200); // Success
    371         $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
    372         $objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID");
    373         $this->getResponse()->addHeader('Location', $objHref);
    374
    375         return $responseFormatter->convertDataObject($obj);
    376     }
    377
    378     /**
    379      * Handler for object append / method call.
    380      *
    381      * @todo Posting to an existing URL (without a relation)
    382      * current resolves in creatig a new element,
    383      * rather than a "Conflict" message.
    384      */
    385     protected function postHandler($className, $id, $relation) {
    386         if($id) {
    387             if(!$relation) {
    388                 $this->response->setStatusCode(409);
    389                 return 'Conflict';
    390             }
    391
    392             $obj = DataObject::get_by_id($className, $id);
    393             if(!$obj) return $this->notFound();
    394
    395             if(!$obj->hasMethod($relation)) {
    396                 return $this->notFound();
    397             }
    398
    399             if(!$obj->stat('allowed_actions') || !in_array($relation, $obj->stat('allowed_actions'))) {
    400                 return $this->permissionFailure();
    401             }
    402
    403             $obj->$relation();
    404
    405             $this->getResponse()->setStatusCode(204); // No Content
    406             return true;
    407         } else {
    408             if(!singleton($className)->canCreate()) return $this->permissionFailure();
    409             $obj = new $className();
    410
    411             $reqFormatter = $this->getRequestDataFormatter();
    412             if(!$reqFormatter) return $this->unsupportedMediaType();
    413
    414             $responseFormatter = $this->getResponseDataFormatter();
    415
    416             $obj = $this->updateDataObject($obj, $reqFormatter);
    417
    418             $this->getResponse()->setStatusCode(201); // Created
    419             $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
    420             $objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID");
    421             $this->getResponse()->addHeader('Location', $objHref);
    422
    423             return $responseFormatter->convertDataObject($obj);
    424         }
    425     }
    426
    427     /**
    428      * Converts either the given HTTP Body into an array
    429      * (based on the DataFormatter instance), or returns
    430      * the POST variables.
    431      * Automatically filters out certain critical fields
    432      * that shouldn't be set by the client (e.g. ID).
    433      *
    434      * @param DataObject $obj
    435      * @param DataFormatter $formatter
    436      * @return DataObject The passed object
    437      */
    438     protected function updateDataObject($obj, $formatter) {
    439         // if neither an http body nor POST data is present, return error
    440         $body = $this->request->getBody();
    441         if(!$body && !$this->request->postVars()) {
    442             $this->getResponse()->setStatusCode(204); // No Content
    443             return 'No Content';
    444         }
    445
    446         if(!empty($body)) {
    447             $data = $formatter->convertStringToArray($body);
    448         } else {
    449             // assume application/x-www-form-urlencoded which is automatically parsed by PHP
    450             $data = $this->request->postVars();
    451         }
    452
    453         // @todo Disallow editing of certain keys in database
    454         $data = array_diff_key($data, array('ID','Created'));
    455
    456         $apiAccess = singleton($this->urlParams['ClassName'])->stat('api_access');
    457         if(is_array($apiAccess) && isset($apiAccess['edit'])) {
    458             $data = array_intersect_key($data, array_combine($apiAccess['edit'],$apiAccess['edit']));
    459         }
    460
    461         $obj->update($data);
    462         $obj->write();
    463
    464         return $obj;
    465     }
    466
    467     /**
    468      * Gets a single DataObject by ID,
    469      * through a request like /api/v1/<MyClass>/<MyID>
    470      *
    471      * @param string $className
    472      * @param int $id
    473      * @param array $params
    474      * @return SQLQuery
    475      */
    476     protected function getObjectQuery($className, $id, $params) {
    477         $baseClass = ClassInfo::baseDataClass($className);
    478         return singleton($className)->extendedSQL(
    479             "\"$baseClass\".\"ID\" = {$id}"
    480         );
    481     }
    482
    483     /**
    484      * @param DataObject $obj
    485      * @param array $params
    486      * @param int|array $sort
    487      * @param int|array $limit
    488      * @return SQLQuery
    489      */
    490     protected function getObjectsQuery($className, $params, $sort, $limit) {
    491         return $this->getSearchQuery($className, $params, $sort, $limit);
    492     }
    493
    494
    495     /**
    496      * @param DataObject $obj
    497      * @param array $params
    498      * @param int|array $sort
    499      * @param int|array $limit
    500      * @param string $relationName
    501      * @return SQLQuery|boolean
    502      */
    503     protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName) {
    504         if($obj->hasMethod("{$relationName}Query")) {
    505             // @todo HACK Switch to ComponentSet->getQuery() once we implement it (and lazy loading)
    506             $query = $obj->{"{$relationName}Query"}(null, $sort, null, $limit);
    507             $relationClass = $obj->{"{$relationName}Class"}();
    508         } elseif($relationClass = $obj->many_many($relationName)) {
    509             // many_many() returns different notation
    510             $relationClass = $relationClass[1];
    511             $query = $obj->getManyManyComponentsQuery($relationName);
    512         } elseif($relationClass = $obj->has_many($relationName)) {
    513             $query = $obj->getComponentsQuery($relationName);
    514         } elseif($relationClass = $obj->has_one($relationName)) {
    515             $query = null;
    516         } else {
    517             return false;
    518         }
    519
    520         // get all results
    521         return $this->getSearchQuery($relationClass, $params, $sort, $limit, $query);
    522     }
    523
    524     protected function permissionFailure() {
    525         // return a 401
    526         $this->getResponse()->setStatusCode(401);
    527         $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
    528         return "You don't have access to this item through the API.";
    529     }
    530
    531     protected function notFound() {
    532         // return a 404
    533         $this->getResponse()->setStatusCode(404);
    534         return "That object wasn't found";
    535     }
    536
    537     protected function methodNotAllowed() {
    538         $this->getResponse()->setStatusCode(405);
    539         return "Method Not Allowed";
    540     }
    541
    542     protected function unsupportedMediaType() {
    543         $this->response->setStatusCode(415); // Unsupported Media Type
    544         return "Unsupported Media Type";
    545     }
    546
    547     protected function authenticate() {
    548         if(!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) return false;
    549
    550         if($member = Member::currentMember()) return $member;
    551         $member = MemberAuthenticator::authenticate(array(
    552             'Email' => $_SERVER['PHP_AUTH_USER'],
    553             'Password' => $_SERVER['PHP_AUTH_PW'],
    554         ), null);
    555
    556         if($member) {
    557             $member->LogIn(false);
    558             return $member;
    559         } else {
    560             return false;
    561         }
    562     }
    563
    564     /**
    565      * Return only relations which have $api_access enabled.
    566      * @todo Respect field level permissions once they are available in core
    567      *
    568      * @param string $class
    569      * @param Member $member
    570      * @return array
    571      */
    572     protected function getAllowedRelations($class, $member = null) {
    573         $allowedRelations = array();
    574         $obj = singleton($class);
    575         $relations = (array)$obj->has_one() + (array)$obj->has_many() + (array)$obj->many_many();
    576         if($relations) foreach($relations as $relName => $relClass) {
    577             if(singleton($relClass)->stat('api_access')) {
    578                 $allowedRelations[] = $relName;
    579             }
    580         }
    581         return $allowedRelations;
    582     }
    583
    584 }
    585
    586 /**
    587  * Restful server handler for a DataObjectSet
    588  *
    589  * @package sapphire
    590  * @subpackage api
    591  */
    592 class RestfulServer_List {
    593     static $url_handlers = array(
    594         '#ID' => 'handleItem',
    595     );
    596
    597     function __construct($list) {
    598         $this->list = $list;
    599     }
    600
    601     function handleItem($request) {
    602         return new RestulServer_Item($this->list->getById($request->param('ID')));
    603     }
    604 }
    605
    606 /**
    607  * Restful server handler for a single DataObject
    608  *
    609  * @package sapphire
    610  * @subpackage api
    611  */
    612 class RestfulServer_Item {
    613     static $url_handlers = array(
    614         '$Relation' => 'handleRelation',
    615     );
    616
    617     function __construct($item) {
    618         $this->item = $item;
    619     }
    620
    621     function handleRelation($request) {
    622         $funcName = $request('Relation');
    623         $relation = $this->item->$funcName();
    624
    625         if($relation instanceof DataObjectSet) return new RestfulServer_List($relation);
    626         else return new RestfulServer_Item($relation);
    627     }
    628 }
    629

[Raise a SilverStripe Framework issue/bug](https://github.com/silverstripe/silverstripe-framework/issues/new)
- [Raise a SilverStripe CMS issue/bug](https://github.com/silverstripe/silverstripe-cms/issues/new)
- Please use the Silverstripe Forums to ask development related questions. -