From 79b823accf0d0928e33cc57d7eefb6cfc5ca6210 Mon Sep 17 00:00:00 2001 From: Dries <dries@buytaert.net> Date: Wed, 21 Nov 2012 12:39:40 -0500 Subject: [PATCH] Issue #1834288 by klausi, ygerasimov: RESTfully request an entity with JSON-LD serialized response. --- .../Plugin/rest/resource/DBLogResource.php | 33 ++--- .../Plugin/rest/resource/EntityResource.php | 35 ++++- .../rest/lib/Drupal/rest/RequestHandler.php | 16 ++- .../rest/lib/Drupal/rest/ResourceResponse.php | 48 +++++++ .../rest/lib/Drupal/rest/Tests/DBLogTest.php | 19 +-- .../rest/lib/Drupal/rest/Tests/DeleteTest.php | 41 +----- .../lib/Drupal/rest/Tests/RESTTestBase.php | 123 ++++++++++++++++-- .../rest/lib/Drupal/rest/Tests/ReadTest.php | 83 ++++++++++++ core/modules/rest/rest.info | 2 + 9 files changed, 317 insertions(+), 83 deletions(-) create mode 100644 core/modules/rest/lib/Drupal/rest/ResourceResponse.php create mode 100644 core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php index afc8d50a2749..5a1fe319e1c5 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php @@ -7,17 +7,19 @@ namespace Drupal\rest\Plugin\rest\resource; -use Drupal\rest\Plugin\ResourceBase; use Drupal\Core\Annotation\Plugin; -use Symfony\Component\HttpFoundation\Response; +use Drupal\Core\Annotation\Translation; +use Drupal\rest\Plugin\ResourceBase; +use Drupal\rest\ResourceResponse; + use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Provides a resource for database watchdog log entries. * * @Plugin( - * id = "dblog", - * label = "Watchdog database log" + * id = "dblog", + * label = @Translation("Watchdog database log") * ) */ class DBLogResource extends ResourceBase { @@ -38,23 +40,24 @@ public function routes() { * * Returns a watchdog log entry for the specified ID. * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. + * @return \Drupal\rest\ResourceResponse + * The response containing the log entry. * * @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'); + $result = db_query("SELECT * FROM {watchdog} WHERE wid = :wid", array(':wid' => $id)) + ->fetchObject(); + if (!empty($result)) { + // Serialization is done here, so we indicate with NULL that there is no + // subsequent serialization necessary. + $response = new ResourceResponse(NULL, 200, array('Content-Type' => 'application/json')); + // @todo remove hard coded format here. + $response->setContent(drupal_json_encode($result)); + return $response; } - // @todo remove hard coded format here. - return new Response(drupal_json_encode($result[0]), 200, array('Content-Type' => 'application/json')); } + throw new NotFoundHttpException('Not Found'); } } diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php index 352438908c10..689fd84ab9b9 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php @@ -8,9 +8,10 @@ namespace Drupal\rest\Plugin\rest\resource; use Drupal\Core\Annotation\Plugin; +use Drupal\Core\Annotation\Translation; use Drupal\Core\Entity\EntityStorageException; use Drupal\rest\Plugin\ResourceBase; -use Symfony\Component\HttpFoundation\Response; +use Drupal\rest\ResourceResponse; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -18,21 +19,41 @@ * Represents entities as resources. * * @Plugin( - * id = "entity", - * label = "Entity", - * derivative = "Drupal\rest\Plugin\Derivative\EntityDerivative" + * id = "entity", + * label = @Translation("Entity"), + * derivative = "Drupal\rest\Plugin\Derivative\EntityDerivative" * ) */ class EntityResource extends ResourceBase { + /** + * Responds to entity GET requests. + * + * @param mixed $id + * The entity ID. + * + * @return \Drupal\rest\ResourceResponse + * The response containing the loaded entity. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function get($id) { + $definition = $this->getDefinition(); + $entity = entity_load($definition['entity_type'], $id); + if ($entity) { + return new ResourceResponse($entity); + } + throw new NotFoundHttpException(t('Entity with ID @id not found', array('@id' => $id))); + } + /** * Responds to entity DELETE requests. * * @param mixed $id * The entity ID. * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. + * @return \Drupal\rest\ResourceResponse + * The HTTP response object. * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ @@ -43,7 +64,7 @@ public function delete($id) { try { $entity->delete(); // Delete responses have an empty body. - return new Response('', 204); + return new ResourceResponse(NULL, 204); } catch (EntityStorageException $e) { throw new HttpException(500, 'Internal Server Error', $e); diff --git a/core/modules/rest/lib/Drupal/rest/RequestHandler.php b/core/modules/rest/lib/Drupal/rest/RequestHandler.php index 6bea36a92764..a374d14c6145 100644 --- a/core/modules/rest/lib/Drupal/rest/RequestHandler.php +++ b/core/modules/rest/lib/Drupal/rest/RequestHandler.php @@ -38,12 +38,26 @@ public function handle($plugin, Request $request, $id = NULL) { $resource = $this->container ->get('plugin.manager.rest') ->getInstance(array('id' => $plugin)); + $received = $request->getContent(); + // @todo De-serialization should happen here if the request is supposed + // to carry incoming data. try { - return $resource->{$method}($id); + $response = $resource->{$method}($id, $received); } catch (HttpException $e) { return new Response($e->getMessage(), $e->getStatusCode(), $e->getHeaders()); } + $data = $response->getResponseData(); + if ($data != NULL) { + // Serialize the response data. + $serializer = $this->container->get('serializer'); + // @todo Replace the format here with something we get from the HTTP + // Accept headers. See http://drupal.org/node/1833440 + $output = $serializer->serialize($data, 'drupal_jsonld'); + $response->setContent($output); + $response->headers->set('Content-Type', 'application/vnd.drupal.ld+json'); + } + return $response; } return new Response('Access Denied', 403); } diff --git a/core/modules/rest/lib/Drupal/rest/ResourceResponse.php b/core/modules/rest/lib/Drupal/rest/ResourceResponse.php new file mode 100644 index 000000000000..a93f25e84b9f --- /dev/null +++ b/core/modules/rest/lib/Drupal/rest/ResourceResponse.php @@ -0,0 +1,48 @@ +<?php + +/** + * @file + * Definition of Drupal\rest\ResourceResponse. + */ + +namespace Drupal\rest; + +use Symfony\Component\HttpFoundation\Response; + +/** + * Contains data for serialization before sending the response. + */ +class ResourceResponse extends Response { + + /** + * Response data that should be serialized. + * + * @var mixed + */ + protected $responseData; + + /** + * Constructor for ResourceResponse objects. + * + * @param mixed $data + * Response data that should be serialized. + * @param int $status + * The response status code. + * @param array $headers + * An array of response headers. + */ + public function __construct($data = NULL, $status = 200, $headers = array()) { + $this->responseData = $data; + parent::__construct('', $status, $headers); + } + + /** + * Returns response data that should be serialized. + * + * @return mixed + * Response data that should be serialized. + */ + public function getResponseData() { + return $this->responseData; + } +} diff --git a/core/modules/rest/lib/Drupal/rest/Tests/DBLogTest.php b/core/modules/rest/lib/Drupal/rest/Tests/DBLogTest.php index a18d064a6aa6..500239743d9c 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/DBLogTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/DBLogTest.php @@ -19,7 +19,7 @@ class DBLogTest extends RESTTestBase { * * @var array */ - public static $modules = array('rest', 'dblog'); + public static $modules = array('jsonld', 'rest', 'dblog'); public static function getInfo() { return array( @@ -32,17 +32,7 @@ public static function getInfo() { 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'); + $this->enableService('dblog'); } /** @@ -60,15 +50,16 @@ public function testWatchdog() { $account = $this->drupalCreateUser(array('restful get dblog')); $this->drupalLogin($account); - $response = $this->httpRequest("dblog/$id", 'GET'); + $response = $this->httpRequest("dblog/$id", 'GET', NULL, 'application/json'); $this->assertResponse(200); + $this->assertHeader('Content-Type', 'application/json'); $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'); + $response = $this->httpRequest("dblog/9999", 'GET', NULL, 'application/json'); $this->assertResponse(404); $this->assertEqual($response, 'Not Found', 'Response message is correct.'); } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php b/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php index 187c38667aa8..4c039c296be5 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php @@ -34,18 +34,7 @@ public static function getInfo() { */ 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'); + $this->enableService('entity:' . $entity_type); // 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)); @@ -81,6 +70,7 @@ public function testDelete() { $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. + $this->enableService(FALSE); $account = $this->drupalCreateUser(); // Reset cURL here because it is confused from our previously used cURL // options. @@ -88,32 +78,7 @@ public function testDelete() { $this->drupalLogin($account); $this->httpRequest('entity/user/' . $account->id(), 'DELETE'); $user = entity_load('user', $account->id(), TRUE); - $this->assertEqual($account->id(), $user->id()); + $this->assertEqual($account->id(), $user->id(), 'User still exists in the database.'); $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()); - } - } } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php b/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php index 3bc875646563..acf480223033 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php @@ -14,6 +14,13 @@ */ abstract class RESTTestBase extends WebTestBase { + /** + * Stores HTTP response headers from the last HTTP request. + * + * @var array + */ + protected $responseHeaders; + /** * Helper function to issue a HTTP request with simpletest's cURL. * @@ -31,36 +38,136 @@ protected function httpRequest($url, $method, $body = NULL, $format = 'applicati 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( + $curl_options = array( CURLOPT_HTTPGET => TRUE, CURLOPT_URL => url($url, $options), - CURLOPT_NOBODY => FALSE) + CURLOPT_NOBODY => FALSE ); + break; + case 'POST': - return $this->curlExec(array( + $curl_options = 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), - )); + ); + break; + case 'PUT': - return $this->curlExec(array( + $curl_options = 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), - )); + ); + break; + case 'DELETE': - return $this->curlExec(array( + $curl_options = array( CURLOPT_HTTPGET => FALSE, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_URL => url($url, array('absolute' => TRUE)), CURLOPT_NOBODY => FALSE, - )); + ); + break; } + // Include all HTTP headers in the response. + $curl_options[CURLOPT_HEADER] = TRUE; + + $response = $this->curlExec($curl_options); + + list($header, $body) = explode("\r\n\r\n", $response, 2); + $header_lines = explode("\r\n", $header); + foreach ($header_lines as $line) { + $parts = explode(':', $line, 2); + $this->responseHeaders[$parts[0]] = isset($parts[1]) ? trim($parts[1]) : ''; + } + + $this->verbose($method . ' request to: ' . $url . + '<hr />Code: ' . curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE) . + '<hr />Response headers: ' . $header . + '<hr />Response body: ' . $body); + + return $body; + } + + /** + * 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' => $this->randomName(), '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()); + } + } + + /** + * Enables the web service interface for a specific entity type. + * + * @param string|FALSE $resource_type + * The resource type that should get web API enabled or FALSE to disable all + * resource types. + */ + protected function enableService($resource_type) { + // Enable web API for this entity type. + $config = config('rest'); + if ($resource_type) { + $config->set('resources', array( + $resource_type => $resource_type, + )); + } + else { + $config->set('resources', array()); + } + $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'); + } + + /** + * Check if a HTTP response header exists and has the expected value. + * + * @param string $header + * The header key, example: Content-Type + * @param string $value + * The header value. + * @param string $message + * (optional) A message to display with the assertion. + * @param string $group + * (optional) The group this message is in, which is displayed in a column + * in test output. Use 'Debug' to indicate this is debugging output. Do not + * translate this string. Defaults to 'Other'; most tests do not override + * this default. + * + * @return bool + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertHeader($header, $value, $message = '', $group = 'Browser') { + $match = isset($this->responseHeaders[$header]) && $this->responseHeaders[$header] == $value; + return $this->assertTrue($match, $message ? $message : 'HTTP response header ' . $header . ' with value ' . $value . ' found.', $group); } } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php b/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php new file mode 100644 index 000000000000..28ffcea36c4a --- /dev/null +++ b/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php @@ -0,0 +1,83 @@ +<?php + +/** + * @file + * Definition of Drupal\rest\test\ReadTest. + */ + +namespace Drupal\rest\Tests; + +use Drupal\rest\Tests\RESTTestBase; + +/** + * Tests resource read operations on test entities, nodes and users. + */ +class ReadTest extends RESTTestBase { + + /** + * Modules to enable. + * + * @var array + */ + public static $modules = array('jsonld', 'rest', 'entity_test'); + + public static function getInfo() { + return array( + 'name' => 'Read resource', + 'description' => 'Tests the retrieval of resources.', + 'group' => 'REST', + ); + } + + /** + * Tests several valid and invalid read requests on all entity types. + */ + public function testRead() { + // @todo once EntityNG is implemented for other entity types use the full + // entity_get_info() for all entity types here. + $entity_test_info = entity_get_info('entity_test'); + $entity_info = array('entity_test' => $entity_test_info); + foreach ($entity_info as $entity_type => $info) { + $this->enableService('entity:' . $entity_type); + // Create a user account that has the required permissions to delete + // resources via the web API. + $account = $this->drupalCreateUser(array('restful get 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(); + // Read it over the web API. + $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, 'application/vnd.drupal.ld+json'); + $this->assertResponse('200', 'HTTP response code is correct.'); + $this->assertHeader('Content-Type', 'application/vnd.drupal.ld+json'); + $data = drupal_json_decode($response); + // Only assert one example property here, other properties should be + // checked in serialization tests. + $this->assertEqual($data['uuid'][LANGUAGE_DEFAULT][0]['value'], $entity->uuid(), 'Entity UUID is correct'); + + // Try to read an entity that does not exist. + $response = $this->httpRequest('entity/' . $entity_type . '/9999', 'GET', NULL, 'application/ld+json'); + $this->assertResponse(404); + $this->assertEqual($response, 'Entity with ID 9999 not found', 'Response message is correct.'); + + // Try to read an entity without proper permissions. + $this->drupalLogout(); + $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, 'application/vnd.drupal.ld+json'); + $this->assertResponse(403); + $this->assertNull(drupal_json_decode($response), 'No valid JSON found.'); + } + // Try to read 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); + $response = $this->httpRequest('entity/user/' . $account->id(), 'GET', NULL, 'application/vnd.drupal.ld+json'); + $this->assertResponse(404); + $this->assertNull(drupal_json_decode($response), 'No valid JSON found.'); + } +} diff --git a/core/modules/rest/rest.info b/core/modules/rest/rest.info index 56c650c316fb..c0bf4bdf7fd6 100644 --- a/core/modules/rest/rest.info +++ b/core/modules/rest/rest.info @@ -3,4 +3,6 @@ description = Exposes entities and other resources as RESTful web API package = Core version = VERSION core = 8.x +; @todo Remove this dependency once hard coding to JSON-LD is gone. +dependencies[] = jsonld configure = admin/config/services/rest -- GitLab