Commit b1933997 authored by Dries's avatar Dries

Issue #1816354 by klausi, ygerasimov: Added a REST module, starting with DELETE.

parent dda716ea
<?php
/**
* @file
* Definition of Drupal\rest\Plugin\Derivative\EntityDerivative.
*/
namespace Drupal\rest\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DerivativeInterface;
/**
* Provides a resource plugin definition for every entity type.
*/
class EntityDerivative implements DerivativeInterface {
/**
* List of derivative definitions.
*
* @var array
*/
protected $derivatives;
/**
* Implements DerivativeInterface::getDerivativeDefinition().
*/
public function getDerivativeDefinition($derivative_id, array $base_plugin_definition) {
if (!isset($this->derivatives)) {
$this->getDerivativeDefinitions($base_plugin_definition);
}
if (isset($this->derivatives[$derivative_id])) {
return $this->derivatives[$derivative_id];
}
}
/**
* Implements DerivativeInterface::getDerivativeDefinitions().
*/
public function getDerivativeDefinitions(array $base_plugin_definition) {
if (!isset($this->derivatives)) {
// Add in the default plugin configuration and the resource type.
foreach (entity_get_info() as $entity_type => $entity_info) {
$this->derivatives[$entity_type] = array(
'id' => 'entity:' . $entity_type,
'entity_type' => $entity_type,
'label' => $entity_info['label'],
);
$this->derivatives[$entity_type] += $base_plugin_definition;
}
}
return $this->derivatives;
}
}
<?php
/**
* @file
* Definition of Drupal\rest\Plugin\ResourceBase.
*/
namespace Drupal\rest\Plugin;
use Drupal\Component\Plugin\PluginBase;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Common base class for resource plugins.
*/
abstract class ResourceBase extends PluginBase {
/**
* Provides an array of permissions suitable for hook_permission().
*
* Every plugin operation method gets its own user permission. Example:
* "restful delete entity:node" with the title "Access DELETE on Node
* resource".
*
* @reutrn array
* The permission array.
*/
public function permissions() {
$permissions = array();
$definition = $this->getDefinition();
foreach ($this->requestMethods() as $method) {
$lowered_method = strtolower($method);
// Only expose permissions where the HTTP request method exists on the
// plugin.
if (method_exists($this, $lowered_method)) {
$permissions["restful $lowered_method $this->plugin_id"] = array(
'title' => t('Access @method on %label resource', array('@method' => $method, '%label' => $definition['label'])),
);
}
}
return $permissions;
}
/**
* Returns a collection of routes with URL path information for the resource.
*
* This method determines where a resource is reachable, what path
* replacements are used, the required HTTP method for the operation etc.
*
* @return \Symfony\Component\Routing\RouteCollection
* A collection of routes that should be registered for this resource.
*/
public function routes() {
$collection = new RouteCollection();
$methods = $this->requestMethods();
foreach ($methods as $method) {
// Only expose routes where the HTTP request method exists on the plugin.
if (method_exists($this, strtolower($method))) {
$prefix = strtr($this->plugin_id, ':', '/');
$route = new Route("/$prefix/{id}", array(
'_controller' => 'Drupal\rest\RequestHandler::handle',
// @todo Once http://drupal.org/node/1793520 is committed we will have
// route object avaialble in the controller so 'plugin' property
// should be changed to '_plugin'.
// @see RequestHandler::handle().
'plugin' => $this->plugin_id,
), array(
// The HTTP method is a requirement for this route.
'_method' => $method,
));
$name = strtr($this->plugin_id, ':', '.');
$collection->add("$name.$method", $route);
}
}
return $collection;
}
/**
* Provides predefined HTTP request methods.
*
* Plugins can override this method to provide additional custom request
* methods.
*
* @return array
* The list of allowed HTTP request method strings.
*/
protected function requestMethods() {
return drupal_map_assoc(array(
'HEAD',
'GET',
'POST',
'PUT',
'DELETE',
'TRACE',
'OPTIONS',
'CONNECT',
'PATCH',
));
}
}
<?php
/**
* @file
* Definition of Drupal\rest\Plugin\Type\ResourcePluginManager.
*/
namespace Drupal\rest\Plugin\Type;
use Drupal\Component\Plugin\PluginManagerBase;
use Drupal\Component\Plugin\Discovery\DerivativeDiscoveryDecorator;
use Drupal\Core\Plugin\Discovery\AnnotatedClassDiscovery;
use Drupal\Component\Plugin\Factory\ReflectionFactory;
/**
* Manages discovery and instantiation of resource plugins.
*/
class ResourcePluginManager extends PluginManagerBase {
/**
* Overrides Drupal\Component\Plugin\PluginManagerBase::__construct().
*/
public function __construct() {
// Create resource plugin derivatives from declaratively defined resources.
$this->discovery = new DerivativeDiscoveryDecorator(new AnnotatedClassDiscovery('rest', 'resource'));
$this->factory = new ReflectionFactory($this);
}
/**
* Overrides Drupal\Component\Plugin\PluginManagerBase::getInstance().
*/
public function getInstance(array $options){
if (isset($options['id'])) {
return $this->createInstance($options['id']);
}
}
}
<?php
/**
* @file
* Definition of Drupal\rest\Plugin\rest\resource\DBLogResource.
*/
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\Core\Annotation\Plugin;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provides a resource for database watchdog log entries.
*
* @Plugin(
* id = "dblog",
* label = "Watchdog database log"
* )
*/
class DBLogResource extends ResourceBase {
/**
* Overrides \Drupal\rest\Plugin\ResourceBase::routes().
*/
public function routes() {
// Only expose routes if the dblog module is enabled.
if (module_exists('dblog')) {
return parent::routes();
}
return new RouteCollection();
}
/**
* Responds to GET requests.
*
* Returns a watchdog log entry for the specified ID.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function get($id = NULL) {
if ($id) {
$result = db_select('watchdog', 'w')
->condition('wid', $id)
->fields('w')
->execute()
->fetchAll();
if (empty($result)) {
throw new NotFoundHttpException('Not Found');
}
// @todo remove hard coded format here.
return new Response(drupal_json_encode($result[0]), 200, array('Content-Type' => 'application/json'));
}
}
}
<?php
/**
* @file
* Definition of Drupal\rest\Plugin\rest\resource\EntityResource.
*/
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Core\Annotation\Plugin;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\rest\Plugin\ResourceBase;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Represents entities as resources.
*
* @Plugin(
* id = "entity",
* label = "Entity",
* derivative = "Drupal\rest\Plugin\Derivative\EntityDerivative"
* )
*/
class EntityResource extends ResourceBase {
/**
* Responds to entity DELETE requests.
*
* @param mixed $id
* The entity ID.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function delete($id) {
$definition = $this->getDefinition();
$entity = entity_load($definition['entity_type'], $id);
if ($entity) {
try {
$entity->delete();
// Delete responses have an empty body.
return new Response('', 204);
}
catch (EntityStorageException $e) {
throw new HttpException(500, 'Internal Server Error', $e);
}
}
throw new NotFoundHttpException(t('Entity with ID @id not found', array('@id' => $id)));
}
}
<?php
/**
* @file
* Definition of Drupal\rest\RequestHandler.
*/
namespace Drupal\rest;
use Symfony\Component\DependencyInjection\ContainerAware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Acts as intermediate request forwarder for resource plugins.
*/
class RequestHandler extends ContainerAware {
/**
* Handles a web API request.
*
* @param string $plugin
* The resource type plugin.
* @param Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
* @param mixed $id
* The resource ID.
*
* @todo Remove $plugin as argument. After http://drupal.org/node/1793520 is
* committed we would be able to access route object as
* $request->attributes->get('_route'). Then we will get plugin as
* '_plugin' property of route object.
*/
public function handle($plugin, Request $request, $id = NULL) {
$method = strtolower($request->getMethod());
if (user_access("restful $method $plugin")) {
$resource = $this->container
->get('plugin.manager.rest')
->getInstance(array('id' => $plugin));
try {
return $resource->{$method}($id);
}
catch (HttpException $e) {
return new Response($e->getMessage(), $e->getStatusCode(), $e->getHeaders());
}
}
return new Response('Access Denied', 403);
}
}
<?php
/**
* @file
* Definition of Drupal\rest\RestBundle.
*/
namespace Drupal\rest;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* Rest dependency injection container.
*/
class RestBundle extends Bundle {
/**
* Overrides Symfony\Component\HttpKernel\Bundle\Bundle::build().
*/
public function build(ContainerBuilder $container) {
// Register the resource manager class with the dependency injection
// container.
$container->register('plugin.manager.rest', 'Drupal\rest\Plugin\Type\ResourcePluginManager');
}
}
<?php
/**
* @file
* Definition of Drupal\rest\test\DBLogTest.
*/
namespace Drupal\rest\Tests;
use Drupal\rest\Tests\RESTTestBase;
/**
* Tests the Watchdog resource to retrieve log messages.
*/
class DBLogTest extends RESTTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('rest', 'dblog');
public static function getInfo() {
return array(
'name' => 'DB Log resource',
'description' => 'Tests the watchdog database log resource.',
'group' => 'REST',
);
}
public function setUp() {
parent::setUp();
// Enable web API for the watchdog resource.
$config = config('rest');
$config->set('resources', array(
'dblog' => 'dblog',
));
$config->save();
// Rebuild routing cache, so that the web API paths are available.
drupal_container()->get('router.builder')->rebuild();
// Reset the Simpletest permission cache, so that the new resource
// permissions get picked up.
drupal_static_reset('checkPermissions');
}
/**
* Writes a log messages and retrieves it via the web API.
*/
public function testWatchdog() {
// Write a log message to the DB.
watchdog('rest_test', 'Test message');
// Get ID of the written message.
$result = db_select('watchdog', 'w')
->condition('type', 'rest_test')
->fields('w', array('wid'))
->execute()
->fetchCol();
$id = $result[0];
// Create a user account that has the required permissions to read
// the watchdog resource via the web API.
$account = $this->drupalCreateUser(array('restful get dblog'));
$this->drupalLogin($account);
$response = $this->httpRequest("dblog/$id", 'GET');
$this->assertResponse(200);
$log = drupal_json_decode($response);
$this->assertEqual($log['wid'], $id, 'Log ID is correct.');
$this->assertEqual($log['type'], 'rest_test', 'Type of log message is correct.');
$this->assertEqual($log['message'], 'Test message', 'Log message text is correct.');
// Request an unknown log entry.
$response = $this->httpRequest("dblog/9999", 'GET');
$this->assertResponse(404);
$this->assertEqual($response, 'Not Found', 'Response message is correct.');
}
}
<?php
/**
* @file
* Definition of Drupal\rest\test\DeleteTest.
*/
namespace Drupal\rest\Tests;
use Drupal\rest\Tests\RESTTestBase;
/**
* Tests resource deletion on user, node and test entities.
*/
class DeleteTest extends RESTTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('rest', 'entity_test');
public static function getInfo() {
return array(
'name' => 'Delete resource',
'description' => 'Tests the deletion of resources.',
'group' => 'REST',
);
}
/**
* Tests several valid and invalid delete requests on all entity types.
*/
public function testDelete() {
foreach (entity_get_info() as $entity_type => $info) {
// Enable web API for this entity type.
$config = config('rest');
$config->set('resources', array(
'entity:' . $entity_type => 'entity:' . $entity_type,
));
$config->save();
// Rebuild routing cache, so that the web API paths are available.
drupal_container()->get('router.builder')->rebuild();
// Reset the Simpletest permission cache, so that the new resource
// permissions get picked up.
drupal_static_reset('checkPermissions');
// Create a user account that has the required permissions to delete
// resources via the web API.
$account = $this->drupalCreateUser(array('restful delete entity:' . $entity_type));
// Reset cURL here because it is confused from our previously used cURL
// options.
unset($this->curlHandle);
$this->drupalLogin($account);
// Create an entity programmatically.
$entity = $this->entityCreate($entity_type);
$entity->save();
// Delete it over the web API.
$response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'DELETE');
// Clear the static cache with entity_load(), otherwise we won't see the
// update.
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertFalse($entity, $entity_type . ' entity is not in the DB anymore.');
$this->assertResponse('204', 'HTTP response code is correct.');
$this->assertEqual($response, '', 'Response body is empty.');
// Try to delete an entity that does not exist.
$response = $this->httpRequest('entity/' . $entity_type . '/9999', 'DELETE');
$this->assertResponse(404);
$this->assertEqual($response, 'Entity with ID 9999 not found', 'Response message is correct.');
// Try to delete an entity without proper permissions.
$this->drupalLogout();
// Re-save entity to the database.
$entity = $this->entityCreate($entity_type);
$entity->save();
$this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'DELETE');
$this->assertResponse(403);
$this->assertNotIdentical(FALSE, entity_load($entity_type, $entity->id(), TRUE), 'The ' . $entity_type . ' entity is still in the database.');
}
// Try to delete a resource which is not web API enabled.
$account = $this->drupalCreateUser();
// Reset cURL here because it is confused from our previously used cURL
// options.
unset($this->curlHandle);
$this->drupalLogin($account);
$this->httpRequest('entity/user/' . $account->id(), 'DELETE');
$user = entity_load('user', $account->id(), TRUE);
$this->assertEqual($account->id(), $user->id());
$this->assertResponse(404);
}
/**
* Creates entity objects based on their types.
*
* Required properties differ from entity type to entity type, so we keep a
* minimum mapping here.
*
* @param string $entity_type
* The type of the entity that should be created..
*
* @return \Drupal\Core\Entity\EntityInterface
* The new entity object.
*/
protected function entityCreate($entity_type) {
switch ($entity_type) {
case 'entity_test':
return entity_create('entity_test', array('name' => 'test', 'user_id' => 1));
case 'node':
return entity_create('node', array('title' => $this->randomString()));
case 'user':
return entity_create('user', array('name' => $this->randomName()));
default:
return entity_create($entity_type, array());
}
}
}
<?php
/**
* @file
* Definition of Drupal\rest\test\RESTTestBase.
*/
namespace Drupal\rest\Tests;
use Drupal\simpletest\WebTestBase;
/**
* Test helper class that provides a REST client method to send HTTP requests.
*/
abstract class RESTTestBase extends WebTestBase {
/**
* Helper function to issue a HTTP request with simpletest's cURL.
*
* @param string $url
* The relative URL, e.g. "entity/node/1"
* @param string $method
* HTTP method, one of GET, POST, PUT or DELETE.
* @param array $body
* Either the body for POST and PUT or additional URL parameters for GET.
* @param string $format
* The MIME type of the transmitted content.
*/
protected function httpRequest($url, $method, $body = NULL, $format = 'application/ld+json') {
switch ($method) {
case 'GET':
// Set query if there are additional GET parameters.
$options = isset($body) ? array('absolute' => TRUE, 'query' => $body) : array('absolute' => TRUE);
return $this->curlExec(array(
CURLOPT_HTTPGET => TRUE,
CURLOPT_URL => url($url, $options),
CURLOPT_NOBODY => FALSE)
);
case 'POST':
return $this->curlExec(array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_POST => TRUE,
CURLOPT_POSTFIELDS => $body,
CURLOPT_URL => url($url, array('absolute' => TRUE)),
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array('Content-Type: ' . $format),
));
case 'PUT':
return $this->curlExec(array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $body,
CURLOPT_URL => url($url, array('absolute' => TRUE)),
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array('Content-Type: ' . $format),
));
case 'DELETE':
return $this->curlExec(array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_URL => url($url, array('absolute' => TRUE)),
CURLOPT_NOBODY => FALSE,
));
}
}
}
<?php
/**
* @file
* Admin pages for REST module.
*/
/**
* Form constructor for the REST admin form.
*
* @ingroup forms
*/
function rest_admin_form($form, &$form_state) {
$resources = drupal_container()
->get('plugin.manager.rest')
->getDefinitions();
$entity_resources = array();
$other_resources = array();
foreach ($resources as $plugin_name => $definition) {
if (strpos($plugin_name, 'entity:') === FALSE) {
$other_resources[$plugin_name] = $definition['label'];
}
else {
$entity_resources[$plugin_name] = $definition['label'];
}
}
asort($entity_resources);
asort($other_resources);
$enabled_resources = config('rest')->get('resources') ?: array();
$form['entity_resources'] = array(
'#type' => 'checkboxes',
'#options' => $entity_resources,
'#default_value' => $enabled_resources,
'#title' => t('Entity resource types that should be exposed as web services:'),
);
if (!empty($other_resources)) {
$form['other_resources'] = array(
'#type' => 'checkboxes',
'#options' => $other_resources,
'#default_value' => $enabled_resources,
'#title' => t('Other available resource types that should be exposed as web services:'),
);
}
return system_config_form($form, $form_state);
}
/**
* Form submission handler for rest_admin_form().
*/
function rest_admin_form_submit($form, &$form_state) {
$resources = array_filter