diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index 63857e4..e436c8b 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php @@ -2,11 +2,16 @@ namespace Drupal\rest\Plugin\rest\resource; +use Drupal\Core\Config\Entity\ConfigEntityType; +use Drupal\Core\Entity\EntityTypeManager; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageException; use Drupal\rest\Plugin\ResourceBase; use Drupal\rest\ResourceResponse; use Drupal\rest\ModifiedResourceResponse; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -30,6 +35,48 @@ class EntityResource extends ResourceBase { /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManager + */ + protected $entityTypeManager; + + /** + * Constructs a Drupal\rest\Plugin\rest\resource\EntityResource object. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param array $serializer_formats + * The available serialization formats. + * @param \Psr\Log\LoggerInterface $logger + * A logger instance. + * @param \Drupal\Core\Entity\EntityTypeManager $entity_type_manager + * The entity type manager. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, array $serializer_formats, LoggerInterface $logger, EntityTypeManager $entity_type_manager) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger); + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.factory')->get('rest'), + $container->get('entity_type.manager') + ); + } + + /** * Responds to entity GET requests. * * @param \Drupal\Core\Entity\EntityInterface $entity @@ -49,13 +96,16 @@ public function get(EntityInterface $entity) { $response = new ResourceResponse($entity, 200); $response->addCacheableDependency($entity); $response->addCacheableDependency($entity_access); - foreach ($entity as $field_name => $field) { - /** @var \Drupal\Core\Field\FieldItemListInterface $field */ - $field_access = $field->access('view', NULL, TRUE); - $response->addCacheableDependency($field_access); - if (!$field_access->isAllowed()) { - $entity->set($field_name, NULL); + if ($entity instanceof FieldableEntityInterface) { + foreach ($entity as $field_name => $field) { + /** @var \Drupal\Core\Field\FieldItemListInterface $field */ + $field_access = $field->access('view', NULL, TRUE); + $response->addCacheableDependency($field_access); + + if (!$field_access->isAllowed()) { + $entity->set($field_name, NULL); + } } } @@ -223,6 +273,10 @@ public function delete(EntityInterface $entity) { * If validation errors are found. */ protected function validate(EntityInterface $entity) { + // @todo Remove when https://www.drupal.org/node/2164373 is committed. + if (!$entity instanceof FieldableEntityInterface) { + return; + } $violations = $entity->validate(); // Remove violations of inaccessible fields as they cannot stem from our @@ -256,4 +310,29 @@ protected function getBaseRoute($canonical_path, $method) { return $route; } + /** + * {@inheritdoc} + */ + public function availableMethods() { + $methods = parent::availableMethods(); + if ($this->isConfigEntityResource()) { + // Currently only GET is supported for Config Entities. + // @todo Remove when supported https://www.drupal.org/node/2300677 + $unsupported_methods = ['POST', 'PUT', 'DELETE', 'PATCH']; + $methods = array_diff($methods, $unsupported_methods); + } + return $methods; + } + + /** + * Checks if this resource is for a Config Entity. + * + * @return bool + * TRUE if the entity is a Config Entity, FALSE otherwise. + */ + protected function isConfigEntityResource() { + $entity_type_id = $this->getPluginDefinition()['entity_type']; + return $this->entityTypeManager->getDefinition($entity_type_id) instanceof ConfigEntityType; + } + } diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 2aa3673..5bbb9be 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -42,6 +42,7 @@ public function handle(RouteMatchInterface $route_match, Request $request) { ->createInstance($plugin); // Deserialize incoming data if available. + /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ $serializer = $this->container->get('serializer'); $received = $request->getContent(); $unserialized = NULL; diff --git a/core/modules/rest/src/Tests/RESTTestBase.php b/core/modules/rest/src/Tests/RESTTestBase.php index 092af7c..4afbf94 100644 --- a/core/modules/rest/src/Tests/RESTTestBase.php +++ b/core/modules/rest/src/Tests/RESTTestBase.php @@ -2,6 +2,7 @@ namespace Drupal\rest\Tests; +use Drupal\Core\Config\Entity\ConfigEntityType; use Drupal\node\NodeInterface; use Drupal\simpletest\WebTestBase; @@ -200,14 +201,14 @@ protected function entityCreate($entity_type) { * 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. + * @param string $entity_type_id + * The id of the type of entity that should be created. * * @return array * An array of values keyed by property name. */ - protected function entityValues($entity_type) { - switch ($entity_type) { + protected function entityValues($entity_type_id) { + switch ($entity_type_id) { case 'entity_test': return array( 'name' => $this->randomMachineName(), @@ -217,6 +218,11 @@ protected function entityValues($entity_type) { 'format' => 'plain_text', )), ); + case 'config_test': + return [ + 'id' => $this->randomMachineName(), + 'label' => 'Test label', + ]; case 'node': return array('title' => $this->randomString(), 'type' => 'resttest'); case 'node_type': @@ -236,8 +242,15 @@ protected function entityValues($entity_type) { 'entity_id' => 'invalid', 'field_name' => 'comment', ]; - + case 'taxonomy_vocabulary': + return [ + 'vid' => 'tags', + 'name' => $this->randomMachineName(), + ]; default: + if ($this->isConfigEntity($entity_type_id)) { + return $this->configEntityValues($entity_type_id); + } return array(); } } @@ -311,7 +324,7 @@ protected function curlExec($curl_options, $redirect = FALSE) { /** * Provides the necessary user permissions for entity operations. * - * @param string $entity_type + * @param string $entity_type_id * The entity type. * @param string $operation * The operation, one of 'view', 'create', 'update' or 'delete'. @@ -319,8 +332,8 @@ protected function curlExec($curl_options, $redirect = FALSE) { * @return array * The set of user permission strings. */ - protected function entityPermissions($entity_type, $operation) { - switch ($entity_type) { + protected function entityPermissions($entity_type_id, $operation) { + switch ($entity_type_id) { case 'entity_test': switch ($operation) { case 'view': @@ -365,9 +378,17 @@ protected function entityPermissions($entity_type, $operation) { default: return ['administer users']; + } + default: + if ($this->isConfigEntity($entity_type_id)) { + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + if ($admin_permission = $entity_type->getAdminPermission()) { + return [$admin_permission]; + } } } + return []; } /** @@ -431,4 +452,49 @@ protected function assertResponseBody($expected, $message = '', $group = 'REST R return $this->assertIdentical($expected, $this->responseBody, $message ? $message : strtr('Response body @expected (expected) is equal to @response (actual).', array('@expected' => var_export($expected, TRUE), '@response' => var_export($this->responseBody, TRUE))), $group); } + /** + * Checks if an entity type id is for a Config Entity. + * + * @param string $entity_type_id + * The entity type id to check. + * + * @return bool + * TRUE if the entity is a Config Entity, FALSE otherwise. + */ + protected function isConfigEntity($entity_type_id) { + return \Drupal::entityTypeManager()->getDefinition($entity_type_id) instanceof ConfigEntityType; + } + + /** + * Provides an array of suitable property values for a config entity type. + * + * Config entities have some common keys that need to be created. Required + * properties differ among config entity types, so we keep a minimum mapping + * here. + * + * @param string $entity_type_id + * The id of the type of entity that should be created. + * + * @return array + * An array of values keyed by property name. + */ + protected function configEntityValues($entity_type_id) { + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + $keys = $entity_type->getKeys(); + $values = []; + // Fill out known key values that are shared across entity types. + foreach ($keys as $key) { + if ($key === 'id' || $key === 'label') { + $values[$key] = $this->randomMachineName(); + } + } + // Add extra values for particular entity types. + switch ($entity_type_id) { + case 'block': + $values['plugin'] = 'system_powered_by_block'; + break; + } + return $values; + } + } diff --git a/core/modules/rest/src/Tests/ReadTest.php b/core/modules/rest/src/Tests/ReadTest.php index 31b5db8..fb455d2 100644 --- a/core/modules/rest/src/Tests/ReadTest.php +++ b/core/modules/rest/src/Tests/ReadTest.php @@ -3,6 +3,8 @@ namespace Drupal\rest\Tests; use Drupal\Component\Serialization\Json; +use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Url; /** @@ -17,7 +19,14 @@ class ReadTest extends RESTTestBase { * * @var array */ - public static $modules = array('hal', 'rest', 'entity_test'); + public static $modules = [ + 'hal', + 'rest', + 'entity_test', + 'config_test', + 'taxonomy', + 'block', + ]; /** * Tests several valid and invalid read requests on all entity types. @@ -25,7 +34,14 @@ class ReadTest extends RESTTestBase { public function testRead() { // @todo Expand this at least to users. // Define the entity types we want to test. - $entity_types = array('entity_test', 'node'); + $entity_types = [ + 'entity_test', + 'node', + 'config_test', + 'taxonomy_vocabulary', + 'block', + 'user_role', + ]; foreach ($entity_types as $entity_type) { $this->enableService('entity:' . $entity_type, 'GET'); // Create a user account that has the required permissions to read @@ -39,23 +55,39 @@ public function testRead() { $entity = $this->entityCreate($entity_type); $entity->save(); // Read it over the REST API. - $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET'); + $response = $this->httpRequest($this->getReadUrl($entity), 'GET'); $this->assertResponse('200', 'HTTP response code is correct.'); $this->assertHeader('content-type', $this->defaultMimeType); $data = Json::decode($response); // Only assert one example property here, other properties should be // checked in serialization tests. - $this->assertEqual($data['uuid'][0]['value'], $entity->uuid(), 'Entity UUID is correct'); + if ($entity instanceof ConfigEntityInterface) { + $this->assertEqual($data['uuid'], $entity->uuid(), 'Entity UUID is correct'); + } + else { + $this->assertEqual($data['uuid'][0]['value'], $entity->uuid(), 'Entity UUID is correct'); + } // Try to read the entity with an unsupported mime format. - $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', 'wrongformat'), 'GET'); + $this->httpRequest($this->getReadUrl($entity, 'wrongformat'), 'GET'); $this->assertResponse(406); $this->assertHeader('Content-type', 'application/json'); // Try to read an entity that does not exist. - $response = $this->httpRequest(Url::fromUri('base://' . $entity_type . '/9999', ['query' => ['_format' => $this->defaultFormat]]), 'GET'); + $response = $this->httpRequest($this->getReadUrl($entity, $this->defaultFormat, 9999), 'GET'); $this->assertResponse(404); - $path = $entity_type == 'node' ? '/node/{node}' : '/entity_test/{entity_test}'; + switch ($entity_type) { + case 'node': + $path = '/node/{node}'; + break; + + case 'entity_test': + $path = '/entity_test/{entity_test}'; + break; + + default: + $path = "/entity/$entity_type/{" . $entity_type . '}'; + } $expected_message = Json::encode(['message' => 'The "' . $entity_type . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . $entity_type . '.GET.hal_json")']); $this->assertIdentical($expected_message, $response, 'Response message is correct.'); @@ -65,7 +97,7 @@ public function testRead() { if ($entity_type == 'entity_test') { $entity->field_test_text->value = 'no access value'; $entity->save(); - $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET'); + $response = $this->httpRequest($this->getReadUrl($entity), 'GET'); $this->assertResponse(200); $this->assertHeader('content-type', $this->defaultMimeType); $data = Json::decode($response); @@ -74,14 +106,15 @@ public function testRead() { // Try to read an entity without proper permissions. $this->drupalLogout(); - $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET'); + $response = $this->httpRequest($this->getReadUrl($entity), 'GET'); $this->assertResponse(403); $this->assertIdentical('{"message":""}', $response); } - // Try to read a resource which is not REST API enabled. + // Try to read a resource, the user entity, which is not REST API enabled. $account = $this->drupalCreateUser(); $this->drupalLogin($account); - $response = $this->httpRequest($account->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET'); + $response = $this->httpRequest($this->getReadUrl($account), 'GET'); + // \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical, // non-REST route a match, but a lower quality one: no format restrictions // means there's always a match and hence when there is no matching REST @@ -111,8 +144,50 @@ public function testResourceStructure() { $entity->save(); // Read it over the REST API. - $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', 'json'), 'GET'); + $this->httpRequest($this->getReadUrl($entity, 'json'), 'GET'); $this->assertResponse('200', 'HTTP response code is correct.'); } + /** + * Gets the read URL object for the entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to get the URL for. + * @param string $format + * The format to request the entity in. + * @param string $entity_id + * The entity id to use in the URL, defaults to the entity's ID if know + * given. + * + * @return \Drupal\Core\Url + * The Url object. + */ + protected function getReadUrl(EntityInterface $entity, $format = NULL, $entity_id = NULL) { + if (!$format) { + $format = $this->defaultFormat; + } + if (!$entity_id) { + $entity_id = $entity->id(); + } + $entity_type = $entity->getEntityTypeId(); + if ($entity->hasLinkTemplate('canonical')) { + $url = $entity->toUrl('canonical'); + } + else { + $route_name = 'rest.entity.' . $entity_type . ".GET."; + // If testing unsupported format don't use the format to construct route + // name. This would give a RouteNotFoundException. + if ($format == 'wrongformat') { + $route_name .= $this->defaultFormat; + } + else { + $route_name .= $format; + } + $url = Url::fromRoute($route_name); + } + $url->setRouteParameter($entity_type, $entity_id); + $url->setRouteParameter('_format', $format); + return $url; + } + }