Drupal 8.0.0 beta 12. More info: https://www.drupal.org/node/2514176

This commit is contained in:
Pantheon Automation 2015-08-17 17:00:26 -07:00 committed by Greg Anderson
commit 9921556621
13277 changed files with 1459781 additions and 0 deletions

View file

@ -0,0 +1,93 @@
<?php
/**
* @file
* Contains \Drupal\rest\Access\CSRFAccessCheck.
*/
namespace Drupal\rest\Access;
use Drupal\Core\Access\AccessCheckInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\SessionConfigurationInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* Access protection against CSRF attacks.
*/
class CSRFAccessCheck implements AccessCheckInterface {
/**
* The session configuration.
*
* @var \Drupal\Core\Session\SessionConfigurationInterface
*/
protected $sessionConfiguration;
/**
* Constructs a new rest CSRF access check.
*
* @param \Drupal\Core\Session\SessionConfigurationInterface $session_configuration
* The session configuration.
*/
public function __construct(SessionConfigurationInterface $session_configuration) {
$this->sessionConfiguration = $session_configuration;
}
/**
* Implements AccessCheckInterface::applies().
*/
public function applies(Route $route) {
$requirements = $route->getRequirements();
if (array_key_exists('_access_rest_csrf', $requirements)) {
if (isset($requirements['_method'])) {
// There could be more than one method requirement separated with '|'.
$methods = explode('|', $requirements['_method']);
// CSRF protection only applies to write operations, so we can filter
// out any routes that require reading methods only.
$write_methods = array_diff($methods, array('GET', 'HEAD', 'OPTIONS', 'TRACE'));
if (empty($write_methods)) {
return FALSE;
}
}
// No method requirement given, so we run this access check to be on the
// safe side.
return TRUE;
}
}
/**
* Checks access.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Request $request, AccountInterface $account) {
$method = $request->getMethod();
// This check only applies if
// 1. this is a write operation
// 2. the user was successfully authenticated and
// 3. the request comes with a session cookie.
if (!in_array($method, array('GET', 'HEAD', 'OPTIONS', 'TRACE'))
&& $account->isAuthenticated()
&& $this->sessionConfiguration->hasSession($request)
) {
$csrf_token = $request->headers->get('X-CSRF-Token');
if (!\Drupal::csrfToken()->validate($csrf_token, 'rest')) {
return AccessResult::forbidden()->setCacheMaxAge(0);
}
}
// Let other access checkers decide if the request is legit.
return AccessResult::allowed()->setCacheMaxAge(0);
}
}

View file

@ -0,0 +1,46 @@
<?php
/**
* @file
* Contains \Drupal\rest\Annotation\RestResource.
*/
namespace Drupal\rest\Annotation;
use \Drupal\Component\Annotation\Plugin;
/**
* Defines a REST resource annotation object.
*
* Plugin Namespace: Plugin\rest\resource
*
* For a working example, see \Drupal\rest\Plugin\rest\resource\DBLogResource
*
* @see \Drupal\rest\Plugin\Type\ResourcePluginManager
* @see \Drupal\rest\Plugin\ResourceBase
* @see \Drupal\rest\Plugin\ResourceInterface
* @see plugin_api
*
* @ingroup third_party
*
* @Annotation
*/
class RestResource extends Plugin {
/**
* The resource plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the resource plugin.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $label;
}

View file

@ -0,0 +1,25 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\ConfigurableLinkManagerInterface.
*/
namespace Drupal\rest\LinkManager;
/**
* Defines an interface for a link manager with a configurable domain.
*/
interface ConfigurableLinkManagerInterface {
/**
* Sets the link domain used in constructing link URIs.
*
* @param string $domain
* The link domain to use for constructing link URIs.
*
* @return $this
*/
public function setLinkDomain($domain);
}

View file

@ -0,0 +1,75 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\LinkManager.
*/
namespace Drupal\rest\LinkManager;
class LinkManager implements LinkManagerInterface {
/**
* The type link manager.
*
* @var \Drupal\rest\LinkManager\TypeLinkManagerInterface
*/
protected $typeLinkManager;
/**
* The relation link manager.
*
* @var \Drupal\rest\LinkManager\RelationLinkManagerInterface
*/
protected $relationLinkManager;
/**
* Constructor.
*
* @param \Drupal\rest\LinkManager\TypeLinkManagerInterface $type_link_manager
* Manager for handling bundle URIs.
* @param \Drupal\rest\LinkManager\RelationLinkManagerInterface $relation_link_manager
* Manager for handling bundle URIs.
*/
public function __construct(TypeLinkManagerInterface $type_link_manager, RelationLinkManagerInterface $relation_link_manager) {
$this->typeLinkManager = $type_link_manager;
$this->relationLinkManager = $relation_link_manager;
}
/**
* Implements \Drupal\rest\LinkManager\TypeLinkManagerInterface::getTypeUri().
*/
public function getTypeUri($entity_type, $bundle, $context = array()) {
return $this->typeLinkManager->getTypeUri($entity_type, $bundle, $context);
}
/**
* Implements \Drupal\rest\LinkManager\TypeLinkManagerInterface::getTypeInternalIds().
*/
public function getTypeInternalIds($type_uri, $context = array()) {
return $this->typeLinkManager->getTypeInternalIds($type_uri, $context);
}
/**
* Implements \Drupal\rest\LinkManager\RelationLinkManagerInterface::getRelationUri().
*/
public function getRelationUri($entity_type, $bundle, $field_name, $context = array()) {
return $this->relationLinkManager->getRelationUri($entity_type, $bundle, $field_name, $context);
}
/**
* Implements \Drupal\rest\LinkManager\RelationLinkManagerInterface::getRelationInternalIds().
*/
public function getRelationInternalIds($relation_uri) {
return $this->relationLinkManager->getRelationInternalIds($relation_uri);
}
/**
* {@inheritdoc}
*/
public function setLinkDomain($domain) {
$this->relationLinkManager->setLinkDomain($domain);
$this->typeLinkManager->setLinkDomain($domain);
return $this;
}
}

View file

@ -0,0 +1,64 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\LinkManagerBase.
*/
namespace Drupal\rest\LinkManager;
use Drupal\Core\Url;
/**
* Defines an abstract base-class for REST link manager objects.
*/
abstract class LinkManagerBase {
/**
* Link domain used for type links URIs.
*
* @var string
*/
protected $linkDomain;
/**
* Config factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* {@inheritdoc}
*/
public function setLinkDomain($domain) {
$this->linkDomain = rtrim($domain, '/');
return $this;
}
/**
* Gets the link domain.
*
* @return string
* The link domain.
*/
protected function getLinkDomain() {
if (empty($this->linkDomain)) {
if ($domain = $this->configFactory->get('rest.settings')->get('link_domain')) {
$this->linkDomain = rtrim($domain, '/');
}
else {
$request = $this->requestStack->getCurrentRequest();
$this->linkDomain = $request->getSchemeAndHttpHost() . $request->getBasePath();
}
}
return $this->linkDomain;
}
}

View file

@ -0,0 +1,23 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\LinkManagerInterface.
*/
namespace Drupal\rest\LinkManager;
/**
* Interface implemented by link managers.
*
* There are no explicit methods on the manager interface. Instead link managers
* broker the interactions of the different components, and therefore must
* implement each component interface, which is enforced by this interface
* extending all of the component ones.
*
* While a link manager may directly implement these interface methods with
* custom logic, it is expected to be more common for plugin managers to proxy
* the method invocations to the respective components.
*/
interface LinkManagerInterface extends TypeLinkManagerInterface, RelationLinkManagerInterface {
}

View file

@ -0,0 +1,134 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\RelationLinkManager.
*/
namespace Drupal\rest\LinkManager;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class RelationLinkManager extends LinkManagerBase implements RelationLinkManagerInterface {
/**
* @var \Drupal\Core\Cache\CacheBackendInterface;
*/
protected $cache;
/**
* Entity manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* Module handler service.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Constructor.
*
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache of relation URIs and their associated Typed Data IDs.
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory service.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
*/
public function __construct(CacheBackendInterface $cache, EntityManagerInterface $entity_manager, ModuleHandlerInterface $module_handler, ConfigFactoryInterface $config_factory, RequestStack $request_stack) {
$this->cache = $cache;
$this->entityManager = $entity_manager;
$this->configFactory = $config_factory;
$this->moduleHandler = $module_handler;
$this->requestStack = $request_stack;
}
/**
* {@inheritdoc}
*/
public function getRelationUri($entity_type, $bundle, $field_name, $context = array()) {
$uri = $this->getLinkDomain() . "/rest/relation/$entity_type/$bundle/$field_name";
$this->moduleHandler->alter('rest_relation_uri', $uri, $context);
return $uri;
}
/**
* {@inheritdoc}
*/
public function getRelationInternalIds($relation_uri, $context = array()) {
$relations = $this->getRelations($context);
if (isset($relations[$relation_uri])) {
return $relations[$relation_uri];
}
return FALSE;
}
/**
* Get the array of relation links.
*
* Any field can be handled as a relation simply by changing how it is
* normalized. Therefore, there is no prior knowledge that can be used here
* to determine which fields to assign relation URIs. Instead, each field,
* even primitives, are given a relation URI. It is up to the caller to
* determine which URIs to use.
*
* @param array $context
* Context from the normalizer/serializer operation.
*
* @return array
* An array of typed data ids (entity_type, bundle, and field name) keyed
* by corresponding relation URI.
*/
protected function getRelations($context = array()) {
$cid = 'rest:links:relations';
$cache = $this->cache->get($cid);
if (!$cache) {
$this->writeCache($context);
$cache = $this->cache->get($cid);
}
return $cache->data;
}
/**
* Writes the cache of relation links.
*
* @param array $context
* Context from the normalizer/serializer operation.
*/
protected function writeCache($context = array()) {
$data = array();
foreach ($this->entityManager->getDefinitions() as $entity_type) {
if ($entity_type instanceof ContentEntityTypeInterface) {
foreach ($this->entityManager->getBundleInfo($entity_type->id()) as $bundle => $bundle_info) {
foreach ($this->entityManager->getFieldDefinitions($entity_type->id(), $bundle) as $field_definition) {
$relation_uri = $this->getRelationUri($entity_type->id(), $bundle, $field_definition->getName(), $context);
$data[$relation_uri] = array(
'entity_type' => $entity_type,
'bundle' => $bundle,
'field_name' => $field_definition->getName(),
);
}
}
}
}
// These URIs only change when field info changes, so cache it permanently
// and only clear it when the fields cache is cleared.
$this->cache->set('rest:links:relations', $data, Cache::PERMANENT, array('entity_field_info'));
}
}

View file

@ -0,0 +1,40 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\RelationLinkManagerInterface.
*/
namespace Drupal\rest\LinkManager;
interface RelationLinkManagerInterface extends ConfigurableLinkManagerInterface {
/**
* Gets the URI that corresponds to a field.
*
* @param string $entity_type
* The bundle's entity type.
* @param string $bundle
* The bundle name.
* @param string $field_name
* The field name.
* @param array $context
* (optional) Optional serializer/normalizer context.
*
* @return string
* The corresponding URI for the field.
*/
public function getRelationUri($entity_type, $bundle, $field_name, $context = array());
/**
* Translates a REST URI into internal IDs.
*
* @param string $relation_uri
* Relation URI to transform into internal IDs
*
* @return array
* Array with keys 'entity_type', 'bundle' and 'field_name'.
*/
public function getRelationInternalIds($relation_uri);
}

View file

@ -0,0 +1,133 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\TypeLinkManager.
*/
namespace Drupal\rest\LinkManager;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class TypeLinkManager extends LinkManagerBase implements TypeLinkManagerInterface {
/**
* Injected cache backend.
*
* @var \Drupal\Core\Cache\CacheBackendInterface;
*/
protected $cache;
/**
* Module handler service.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Constructor.
*
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The injected cache backend for caching type URIs.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory service.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
*/
public function __construct(CacheBackendInterface $cache, ModuleHandlerInterface $module_handler, ConfigFactoryInterface $config_factory, RequestStack $request_stack) {
$this->cache = $cache;
$this->configFactory = $config_factory;
$this->moduleHandler = $module_handler;
$this->requestStack = $request_stack;
}
/**
* Get a type link for a bundle.
*
* @param string $entity_type
* The bundle's entity type.
* @param string $bundle
* The name of the bundle.
* @param array $context
* Context of normalizer/serializer.
*
* @return string
* The URI that identifies this bundle.
*/
public function getTypeUri($entity_type, $bundle, $context = array()) {
$uri = $this->getLinkDomain() . "/rest/type/$entity_type/$bundle";
$this->moduleHandler->alter('rest_type_uri', $uri, $context);
return $uri;
}
/**
* Implements \Drupal\rest\LinkManager\TypeLinkManagerInterface::getTypeInternalIds().
*/
public function getTypeInternalIds($type_uri, $context = array()) {
$types = $this->getTypes($context);
if (isset($types[$type_uri])) {
return $types[$type_uri];
}
return FALSE;
}
/**
* Get the array of type links.
*
* @param array $context
* Context from the normalizer/serializer operation.
*
* @return array
* An array of typed data ids (entity_type and bundle) keyed by
* corresponding type URI.
*/
protected function getTypes($context = array()) {
$cid = 'rest:links:types';
$cache = $this->cache->get($cid);
if (!$cache) {
$this->writeCache($context);
$cache = $this->cache->get($cid);
}
return $cache->data;
}
/**
* Writes the cache of type links.
*
* @param array $context
* Context from the normalizer/serializer operation.
*/
protected function writeCache($context = array()) {
$data = array();
// Type URIs correspond to bundles. Iterate through the bundles to get the
// URI and data for them.
$entity_types = \Drupal::entityManager()->getDefinitions();
foreach (entity_get_bundles() as $entity_type_id => $bundles) {
// Only content entities are supported currently.
// @todo Consider supporting config entities.
if ($entity_types[$entity_type_id]->isSubclassOf('\Drupal\Core\Config\Entity\ConfigEntityInterface')) {
continue;
}
foreach ($bundles as $bundle => $bundle_info) {
// Get a type URI for the bundle.
$bundle_uri = $this->getTypeUri($entity_type_id, $bundle, $context);
$data[$bundle_uri] = array(
'entity_type' => $entity_type_id,
'bundle' => $bundle,
);
}
}
// These URIs only change when entity info changes, so cache it permanently
// and only clear it when entity_info is cleared.
$this->cache->set('rest:links:types', $data, Cache::PERMANENT, array('entity_types'));
}
}

View file

@ -0,0 +1,44 @@
<?php
/**
* @file
* Contains \Drupal\rest\LinkManager\TypeLinkManagerInterface.
*/
namespace Drupal\rest\LinkManager;
interface TypeLinkManagerInterface extends ConfigurableLinkManagerInterface {
/**
* Gets the URI that corresponds to a bundle.
*
* When using hypermedia formats, this URI can be used to indicate which
* bundle the data represents. Documentation about required and optional
* fields can also be provided at this URI.
*
* @param $entity_type
* The bundle's entity type.
* @param $bundle
* The bundle name.
* @param array $context
* (optional) Optional serializer/normalizer context.
*
* @return string
* The corresponding URI for the bundle.
*/
public function getTypeUri($entity_type, $bundle, $context = array());
/**
* Get a bundle's Typed Data IDs based on a URI.
*
* @param string $type_uri
* The type URI.
* @param array $context
* Context from the normalizer/serializer operation.
*
* @return array | boolean
* If the URI matches a bundle, returns an array containing entity_type and
* bundle. Otherwise, returns false.
*/
public function getTypeInternalIds($type_uri, $context = array());
}

View file

@ -0,0 +1,104 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\Deriver\EntityDeriver.
*/
namespace Drupal\rest\Plugin\Deriver;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\Routing\RouteBuilderInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
/**
* Provides a resource plugin definition for every entity type.
*
* @see \Drupal\rest\Plugin\rest\resource\EntityResource
*/
class EntityDeriver implements ContainerDeriverInterface {
/**
* List of derivative definitions.
*
* @var array
*/
protected $derivatives;
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* Constructs an EntityDerivative object.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
*/
public function __construct(EntityManagerInterface $entity_manager) {
$this->entityManager = $entity_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('entity.manager')
);
}
/**
* Implements DerivativeInterface::getDerivativeDefinition().
*/
public function getDerivativeDefinition($derivative_id, $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($base_plugin_definition) {
if (!isset($this->derivatives)) {
// Add in the default plugin configuration and the resource type.
foreach ($this->entityManager->getDefinitions() as $entity_type_id => $entity_type) {
$this->derivatives[$entity_type_id] = array(
'id' => 'entity:' . $entity_type_id,
'entity_type' => $entity_type_id,
'serialization_class' => $entity_type->getClass(),
'label' => $entity_type->getLabel(),
);
$default_uris = array(
'canonical' => "/entity/$entity_type_id/" . '{' . $entity_type_id . '}',
'https://www.drupal.org/link-relations/create' => "/entity/$entity_type_id",
);
foreach ($default_uris as $link_relation => $default_uri) {
// Check if there are link templates defined for the entity type and
// use the path from the route instead of the default.
if ($link_template = $entity_type->getLinkTemplate($link_relation)) {
$this->derivatives[$entity_type_id]['uri_paths'][$link_relation] = '/' . $link_template;
}
else {
$this->derivatives[$entity_type_id]['uri_paths'][$link_relation] = $default_uri;
}
}
$this->derivatives[$entity_type_id] += $base_plugin_definition;
}
}
return $this->derivatives;
}
}

View file

@ -0,0 +1,217 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\ResourceBase.
*/
namespace Drupal\rest\Plugin;
use Drupal\Core\Access\AccessManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Common base class for resource plugins.
*
* @see \Drupal\rest\Annotation\RestResource
* @see \Drupal\rest\Plugin\Type\ResourcePluginManager
* @see \Drupal\rest\Plugin\ResourceInterface
* @see plugin_api
*
* @ingroup third_party
*/
abstract class ResourceBase extends PluginBase implements ContainerFactoryPluginInterface, ResourceInterface {
/**
* The available serialization formats.
*
* @var array
*/
protected $serializerFormats = array();
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Constructs a Drupal\rest\Plugin\ResourceBase 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.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, array $serializer_formats, LoggerInterface $logger) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->serializerFormats = $serializer_formats;
$this->logger = $logger;
}
/**
* {@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')
);
}
/**
* Implements ResourceInterface::permissions().
*
* Every plugin operation method gets its own user permission. Example:
* "restful delete entity:node" with the title "Access DELETE on Node
* resource".
*/
public function permissions() {
$permissions = array();
$definition = $this->getPluginDefinition();
foreach ($this->availableMethods() as $method) {
$lowered_method = strtolower($method);
$permissions["restful $lowered_method $this->pluginId"] = array(
'title' => $this->t('Access @method on %label resource', array('@method' => $method, '%label' => $definition['label'])),
);
}
return $permissions;
}
/**
* Implements ResourceInterface::routes().
*/
public function routes() {
$collection = new RouteCollection();
$definition = $this->getPluginDefinition();
$canonical_path = isset($definition['uri_paths']['canonical']) ? $definition['uri_paths']['canonical'] : '/' . strtr($this->pluginId, ':', '/') . '/{id}';
$create_path = isset($definition['uri_paths']['https://www.drupal.org/link-relations/create']) ? $definition['uri_paths']['https://www.drupal.org/link-relations/create'] : '/' . strtr($this->pluginId, ':', '/');
$route_name = strtr($this->pluginId, ':', '.');
$methods = $this->availableMethods();
foreach ($methods as $method) {
$route = $this->getBaseRoute($canonical_path, $method);
switch ($method) {
case 'POST':
$route->setPath($create_path);
// Restrict the incoming HTTP Content-type header to the known
// serialization formats.
$route->addRequirements(array('_content_type_format' => implode('|', $this->serializerFormats)));
$collection->add("$route_name.$method", $route);
break;
case 'PATCH':
// Restrict the incoming HTTP Content-type header to the known
// serialization formats.
$route->addRequirements(array('_content_type_format' => implode('|', $this->serializerFormats)));
$collection->add("$route_name.$method", $route);
break;
case 'GET':
case 'HEAD':
// Restrict GET and HEAD requests to the media type specified in the
// HTTP Accept headers.
foreach ($this->serializerFormats as $format_name) {
// Expose one route per available format.
$format_route = clone $route;
$format_route->addRequirements(array('_format' => $format_name));
$collection->add("$route_name.$method.$format_name", $format_route);
}
break;
default:
$collection->add("$route_name.$method", $route);
break;
}
}
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 array(
'HEAD',
'GET',
'POST',
'PUT',
'DELETE',
'TRACE',
'OPTIONS',
'CONNECT',
'PATCH',
);
}
/**
* Implements ResourceInterface::availableMethods().
*/
public function availableMethods() {
$methods = $this->requestMethods();
$available = array();
foreach ($methods as $method) {
// Only expose methods where the HTTP request method exists on the plugin.
if (method_exists($this, strtolower($method))) {
$available[] = $method;
}
}
return $available;
}
/**
* Setups the base route for all HTTP methods.
*
* @param string $canonical_path
* The canonical path for the resource.
* @param string $method
* The HTTP method to be used for the route.
*
* @return \Symfony\Component\Routing\Route
* The created base route.
*/
protected function getBaseRoute($canonical_path, $method) {
$lower_method = strtolower($method);
$route = new Route($canonical_path, array(
'_controller' => 'Drupal\rest\RequestHandler::handle',
// Pass the resource plugin ID along as default property.
'_plugin' => $this->pluginId,
), array(
'_permission' => "restful $lower_method $this->pluginId",
),
array(),
'',
array(),
// The HTTP method is a requirement for this route.
array($method)
);
return $route;
}
}

View file

@ -0,0 +1,54 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\ResourceInterface.
*/
namespace Drupal\rest\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
/**
* Specifies the publicly available methods of a resource plugin.
*
* @see \Drupal\rest\Annotation\RestResource
* @see \Drupal\rest\Plugin\Type\ResourcePluginManager
* @see \Drupal\rest\Plugin\ResourceBase
* @see plugin_api
*
* @ingroup third_party
*/
interface ResourceInterface extends PluginInspectionInterface {
/**
* 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();
/**
* Provides an array of permissions suitable for .permissions.yml files.
*
* A resource plugin can define a set of user permissions that are used on the
* routes for this resource or for other purposes.
*
* @return array
* The permission array.
*/
public function permissions();
/**
* Returns the available HTTP request methods on this plugin.
*
* @return array
* The list of supported methods. Example: array('GET', 'POST', 'PATCH').
*/
public function availableMethods();
}

View file

@ -0,0 +1,50 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\Type\ResourcePluginManager.
*/
namespace Drupal\rest\Plugin\Type;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Manages discovery and instantiation of resource plugins.
*
* @see \Drupal\rest\Annotation\RestResource
* @see \Drupal\rest\Plugin\ResourceBase
* @see \Drupal\rest\Plugin\ResourceInterface
* @see plugin_api
*/
class ResourcePluginManager extends DefaultPluginManager {
/**
* Constructs a new \Drupal\rest\Plugin\Type\ResourcePluginManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/rest/resource', $namespaces, $module_handler, 'Drupal\rest\Plugin\ResourceInterface', 'Drupal\rest\Annotation\RestResource');
$this->setCacheBackend($cache_backend, 'rest_plugins');
$this->alterInfo('rest_resource');
}
/**
* Overrides Drupal\Component\Plugin\PluginManagerBase::getInstance().
*/
public function getInstance(array $options){
if (isset($options['id'])) {
return $this->createInstance($options['id']);
}
}
}

View file

@ -0,0 +1,245 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\rest\resource\EntityResource.
*/
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Drupal\Component\Utility\SafeMarkup;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Represents entities as resources.
*
* @RestResource(
* id = "entity",
* label = @Translation("Entity"),
* serialization_class = "Drupal\Core\Entity\Entity",
* deriver = "Drupal\rest\Plugin\Deriver\EntityDeriver",
* uri_paths = {
* "canonical" = "/entity/{entity_type}/{entity}",
* "https://www.drupal.org/link-relations/create" = "/entity/{entity_type}"
* }
* )
*
* @see \Drupal\rest\Plugin\Derivative\EntityDerivative
*/
class EntityResource extends ResourceBase {
/**
* Responds to entity GET requests.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object.
*
* @return \Drupal\rest\ResourceResponse
* The response containing the entity with its accessible fields.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function get(EntityInterface $entity) {
if (!$entity->access('view')) {
throw new AccessDeniedHttpException();
}
foreach ($entity as $field_name => $field) {
if (!$field->access('view')) {
unset($entity->{$field_name});
}
}
$response = new ResourceResponse($entity, 200);
// Make the response use the entity's cacheability metadata.
// @todo include access cacheability metadata, for the access checks above.
$response->addCacheableDependency($entity);
return $response;
}
/**
* Responds to entity POST requests and saves the new entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Drupal\rest\ResourceResponse
* The HTTP response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function post(EntityInterface $entity = NULL) {
if ($entity == NULL) {
throw new BadRequestHttpException('No entity content received.');
}
if (!$entity->access('create')) {
throw new AccessDeniedHttpException();
}
$definition = $this->getPluginDefinition();
// Verify that the deserialized entity is of the type that we expect to
// prevent security issues.
if ($entity->getEntityTypeId() != $definition['entity_type']) {
throw new BadRequestHttpException('Invalid entity type');
}
// POSTed entities must not have an ID set, because we always want to create
// new entities here.
if (!$entity->isNew()) {
throw new BadRequestHttpException('Only new entities can be created');
}
// Only check 'edit' permissions for fields that were actually
// submitted by the user. Field access makes no difference between 'create'
// and 'update', so the 'edit' operation is used here.
foreach ($entity->_restSubmittedFields as $key => $field_name) {
if (!$entity->get($field_name)->access('edit')) {
throw new AccessDeniedHttpException(SafeMarkup::format('Access denied on creating field @field', array('@field' => $field_name)));
}
}
// Validate the received data before saving.
$this->validate($entity);
try {
$entity->save();
$this->logger->notice('Created entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id()));
// 201 Created responses have an empty body.
return new ResourceResponse(NULL, 201, array('Location' => $entity->url('canonical', ['absolute' => TRUE])));
}
catch (EntityStorageException $e) {
throw new HttpException(500, 'Internal Server Error', $e);
}
}
/**
* Responds to entity PATCH requests.
*
* @param \Drupal\Core\Entity\EntityInterface $original_entity
* The original entity object.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Drupal\rest\ResourceResponse
* The HTTP response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function patch(EntityInterface $original_entity, EntityInterface $entity = NULL) {
if ($entity == NULL) {
throw new BadRequestHttpException('No entity content received.');
}
$definition = $this->getPluginDefinition();
if ($entity->getEntityTypeId() != $definition['entity_type']) {
throw new BadRequestHttpException('Invalid entity type');
}
if (!$original_entity->access('update')) {
throw new AccessDeniedHttpException();
}
// Overwrite the received properties.
$langcode_key = $entity->getEntityType()->getKey('langcode');
foreach ($entity->_restSubmittedFields as $field_name) {
$field = $entity->get($field_name);
// It is not possible to set the language to NULL as it is automatically
// re-initialized. As it must not be empty, skip it if it is.
if ($field_name == $langcode_key && $field->isEmpty()) {
continue;
}
if (!$original_entity->get($field_name)->access('edit')) {
throw new AccessDeniedHttpException(SafeMarkup::format('Access denied on updating field @field.', array('@field' => $field_name)));
}
$original_entity->set($field_name, $field->getValue());
}
// Validate the received data before saving.
$this->validate($original_entity);
try {
$original_entity->save();
$this->logger->notice('Updated entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id()));
// Update responses have an empty body.
return new ResourceResponse(NULL, 204);
}
catch (EntityStorageException $e) {
throw new HttpException(500, 'Internal Server Error', $e);
}
}
/**
* Responds to entity DELETE requests.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object.
*
* @return \Drupal\rest\ResourceResponse
* The HTTP response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function delete(EntityInterface $entity) {
if (!$entity->access('delete')) {
throw new AccessDeniedHttpException();
}
try {
$entity->delete();
$this->logger->notice('Deleted entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id()));
// Delete responses have an empty body.
return new ResourceResponse(NULL, 204);
}
catch (EntityStorageException $e) {
throw new HttpException(500, 'Internal Server Error', $e);
}
}
/**
* Verifies that the whole entity does not violate any validation constraints.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
* If validation errors are found.
*/
protected function validate(EntityInterface $entity) {
$violations = $entity->validate();
// Remove violations of inaccessible fields as they cannot stem from our
// changes.
$violations->filterByFieldAccess();
if (count($violations) > 0) {
$message = "Unprocessable Entity: validation failed.\n";
foreach ($violations as $violation) {
$message .= $violation->getPropertyPath() . ': ' . $violation->getMessage() . "\n";
}
// Instead of returning a generic 400 response we use the more specific
// 422 Unprocessable Entity code from RFC 4918. That way clients can
// distinguish between general syntax errors in bad serializations (code
// 400) and semantic errors in well-formed requests (code 422).
throw new HttpException(422, $message);
}
}
/**
* {@inheritdoc}
*/
protected function getBaseRoute($canonical_path, $method) {
$route = parent::getBaseRoute($canonical_path, $method);
$definition = $this->getPluginDefinition();
$parameters = $route->getOption('parameters') ?: array();
$parameters[$definition['entity_type']]['type'] = 'entity:' . $definition['entity_type'];
$route->setOption('parameters', $parameters);
return $route;
}
}

View file

@ -0,0 +1,306 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\views\display\RestExport.
*/
namespace Drupal\rest\Plugin\views\display;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\views\Plugin\views\display\ResponseDisplayPluginInterface;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\PathPluginBase;
use Symfony\Component\Routing\RouteCollection;
/**
* The plugin that handles Data response callbacks for REST resources.
*
* @ingroup views_display_plugins
*
* @ViewsDisplay(
* id = "rest_export",
* title = @Translation("REST export"),
* help = @Translation("Create a REST export resource."),
* uses_route = TRUE,
* admin = @Translation("REST export"),
* returns_response = TRUE
* )
*/
class RestExport extends PathPluginBase implements ResponseDisplayPluginInterface {
/**
* Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase::$usesAJAX.
*/
protected $usesAJAX = FALSE;
/**
* Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase::$usesPager.
*/
protected $usesPager = FALSE;
/**
* Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase::$usesMore.
*/
protected $usesMore = FALSE;
/**
* Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase::$usesAreas.
*/
protected $usesAreas = FALSE;
/**
* Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase::$usesAreas.
*/
protected $usesOptions = FALSE;
/**
* Overrides the content type of the data response, if needed.
*
* @var string
*/
protected $contentType = 'json';
/**
* The mime type for the response.
*
* @var string
*/
protected $mimeType;
/**
* {@inheritdoc}
*/
public function initDisplay(ViewExecutable $view, array &$display, array &$options = NULL) {
parent::initDisplay($view, $display, $options);
$request_content_type = $this->view->getRequest()->getRequestFormat();
// Only use the requested content type if it's not 'html'. If it is then
// default to 'json' to aid debugging.
// @todo Remove the need for this when we have better content negotiation.
if ($request_content_type != 'html') {
$this->setContentType($request_content_type);
}
// If the requested content type is 'html' and the default 'json' is not
// selected as a format option in the view display, fallback to the first
// format in the array.
elseif (!empty($options['style']['options']['formats']) && !isset($options['style']['options']['formats'][$this->getContentType()])) {
$this->setContentType(reset($options['style']['options']['formats']));
}
$this->setMimeType($this->view->getRequest()->getMimeType($this->contentType));
}
/**
* {@inheritdoc}
*/
protected function getType() {
return 'data';
}
/**
* {@inheritdoc}
*/
public function usesExposed() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function displaysExposed() {
return FALSE;
}
/**
* Sets the request content type.
*
* @param string $mime_type
* The response mime type. E.g. 'application/json'.
*/
public function setMimeType($mime_type) {
$this->mimeType = $mime_type;
}
/**
* Gets the mime type.
*
* This will return any overridden mime type, otherwise returns the mime type
* from the request.
*
* @return string
* The response mime type. E.g. 'application/json'.
*/
public function getMimeType() {
return $this->mimeType;
}
/**
* Sets the content type.
*
* @param string $content_type
* The content type machine name. E.g. 'json'.
*/
public function setContentType($content_type) {
$this->contentType = $content_type;
}
/**
* Gets the content type.
*
* @return string
* The content type machine name. E.g. 'json'.
*/
public function getContentType() {
return $this->contentType;
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
// Set the default style plugin to 'json'.
$options['style']['contains']['type']['default'] = 'serializer';
$options['row']['contains']['type']['default'] = 'data_entity';
$options['defaults']['default']['style'] = FALSE;
$options['defaults']['default']['row'] = FALSE;
// Remove css/exposed form settings, as they are not used for the data display.
unset($options['exposed_form']);
unset($options['exposed_block']);
unset($options['css_class']);
return $options;
}
/**
* {@inheritdoc}
*/
public function optionsSummary(&$categories, &$options) {
parent::optionsSummary($categories, $options);
unset($categories['page'], $categories['exposed']);
// Hide some settings, as they aren't useful for pure data output.
unset($options['show_admin_links'], $options['analyze-theme']);
$categories['path'] = array(
'title' => $this->t('Path settings'),
'column' => 'second',
'build' => array(
'#weight' => -10,
),
);
$options['path']['category'] = 'path';
$options['path']['title'] = $this->t('Path');
// Remove css/exposed form settings, as they are not used for the data
// display.
unset($options['exposed_form']);
unset($options['exposed_block']);
unset($options['css_class']);
}
/**
* {@inheritdoc}
*/
public function collectRoutes(RouteCollection $collection) {
parent::collectRoutes($collection);
$view_id = $this->view->storage->id();
$display_id = $this->display['id'];
if ($route = $collection->get("view.$view_id.$display_id")) {
$style_plugin = $this->getPlugin('style');
// REST exports should only respond to get methods.
$route->setMethods(['GET']);
// Format as a string using pipes as a delimiter.
if ($formats = $style_plugin->getFormats()) {
// Allow a REST Export View to be returned with an HTML-only accept
// format. That allows browsers or other non-compliant systems to access
// the view, as it is unlikely to have a conflicting HTML representation
// anyway.
$route->setRequirement('_format', implode('|', $formats + ['html']));
}
}
}
/**
* {@inheritdoc}
*/
public static function buildResponse($view_id, $display_id, array $args = []) {
$build = static::buildBasicRenderable($view_id, $display_id, $args);
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$output = $renderer->renderRoot($build);
$response = new CacheableResponse($output, 200);
$cache_metadata = CacheableMetadata::createFromRenderArray($build);
$response->addCacheableDependency($cache_metadata);
$response->headers->set('Content-type', $build['#content_type']);
return $response;
}
/**
* {@inheritdoc}
*/
public function execute() {
parent::execute();
return $this->view->render();
}
/**
* {@inheritdoc}
*/
public function render() {
$build = array();
$build['#markup'] = $this->view->style_plugin->render();
$this->view->element['#content_type'] = $this->getMimeType();
$this->view->element['#cache_properties'][] = '#content_type';
// Wrap the output in a pre tag if this is for a live preview.
if (!empty($this->view->live_preview)) {
$build['#prefix'] = '<pre>';
$build['#markup'] = SafeMarkup::checkPlain($build['#markup']);
$build['#suffix'] = '</pre>';
}
elseif ($this->view->getRequest()->getFormat($this->view->element['#content_type']) !== 'html') {
// This display plugin is primarily for returning non-HTML formats.
// However, we still invoke the renderer to collect cacheability metadata.
// Because the renderer is designed for HTML rendering, it filters
// #markup for XSS unless it is already known to be safe, but that filter
// only works for HTML. Therefore, we mark the contents as safe to bypass
// the filter. So long as we are returning this in a non-HTML response
// (checked above), this is safe, because an XSS attack only works when
// executed by an HTML agent.
// @todo Decide how to support non-HTML in the render API in
// https://www.drupal.org/node/2501313.
$build['#markup'] = SafeMarkup::set($build['#markup']);
}
parent::applyDisplayCachablityMetadata($build);
return $build;
}
/**
* {@inheritdoc}
*
* The DisplayPluginBase preview method assumes we will be returning a render
* array. The data plugin will already return the serialized string.
*/
public function preview() {
return $this->view->render();
}
}

View file

@ -0,0 +1,39 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\views\row\DataEntityRow.
*/
namespace Drupal\rest\Plugin\views\row;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\row\RowPluginBase;
/**
* Plugin which displays entities as raw data.
*
* @ingroup views_row_plugins
*
* @ViewsRow(
* id = "data_entity",
* title = @Translation("Entity"),
* help = @Translation("Use entities as row data."),
* display_types = {"data"}
* )
*/
class DataEntityRow extends RowPluginBase {
/**
* Overrides \Drupal\views\Plugin\Plugin::$usesOptions.
*/
protected $usesOptions = FALSE;
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::render().
*/
public function render($row) {
return $row->_entity;
}
}

View file

@ -0,0 +1,191 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\views\row\DataFieldRow.
*/
namespace Drupal\rest\Plugin\views\row;
use Drupal\Core\Form\FormStateInterface;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Plugin\views\row\RowPluginBase;
/**
* Plugin which displays fields as raw data.
*
* @ingroup views_row_plugins
*
* @ViewsRow(
* id = "data_field",
* title = @Translation("Fields"),
* help = @Translation("Use fields as row data."),
* display_types = {"data"}
* )
*/
class DataFieldRow extends RowPluginBase {
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::$usesFields.
*/
protected $usesFields = TRUE;
/**
* Stores an array of prepared field aliases from options.
*
* @var array
*/
protected $replacementAliases = array();
/**
* Stores an array of options to determine if the raw field output is used.
*
* @var array
*/
protected $rawOutputOptions = array();
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::init().
*/
public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) {
parent::init($view, $display, $options);
if (!empty($this->options['field_options'])) {
$options = (array) $this->options['field_options'];
// Prepare a trimmed version of replacement aliases.
$aliases = static::extractFromOptionsArray('alias', $options);
$this->replacementAliases = array_filter(array_map('trim', $aliases));
// Prepare an array of raw output field options.
$this->rawOutputOptions = static::extractFromOptionsArray('raw_output', $options);
}
}
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::buildOptionsForm().
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['field_options'] = array('default' => array());
return $options;
}
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::buildOptionsForm().
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
parent::buildOptionsForm($form, $form_state);
$form['field_options'] = array(
'#type' => 'table',
'#header' => array($this->t('Field'), $this->t('Alias'), $this->t('Raw output')),
'#empty' => $this->t('You have no fields. Add some to your view.'),
'#tree' => TRUE,
);
$options = $this->options['field_options'];
if ($fields = $this->view->display_handler->getOption('fields')) {
foreach ($fields as $id => $field) {
$form['field_options'][$id]['field'] = array(
'#markup' => $id,
);
$form['field_options'][$id]['alias'] = array(
'#title' => $this->t('Alias for @id', array('@id' => $id)),
'#title_display' => 'invisible',
'#type' => 'textfield',
'#default_value' => isset($options[$id]['alias']) ? $options[$id]['alias'] : '',
'#element_validate' => array(array($this, 'validateAliasName')),
);
$form['field_options'][$id]['raw_output'] = array(
'#title' => $this->t('Raw output for @id', array('@id' => $id)),
'#title_display' => 'invisible',
'#type' => 'checkbox',
'#default_value' => isset($options[$id]['raw_output']) ? $options[$id]['raw_output'] : '',
);
}
}
}
/**
* Form element validation handler for \Drupal\rest\Plugin\views\row\DataFieldRow::buildOptionsForm().
*/
public function validateAliasName($element, FormStateInterface $form_state) {
if (preg_match('@[^A-Za-z0-9_-]+@', $element['#value'])) {
$form_state->setError($element, $this->t('The machine-readable name must contain only letters, numbers, dashes and underscores.'));
}
}
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::validateOptionsForm().
*/
public function validateOptionsForm(&$form, FormStateInterface $form_state) {
// Collect an array of aliases to validate.
$aliases = static::extractFromOptionsArray('alias', $form_state->getValue(array('row_options', 'field_options')));
// If array filter returns empty, no values have been entered. Unique keys
// should only be validated if we have some.
if (($filtered = array_filter($aliases)) && (array_unique($filtered) !== $filtered)) {
$form_state->setErrorByName('aliases', $this->t('All field aliases must be unique'));
}
}
/**
* Overrides \Drupal\views\Plugin\views\row\RowPluginBase::render().
*/
public function render($row) {
$output = array();
foreach ($this->view->field as $id => $field) {
// If this is not unknown and the raw output option has been set, just get
// the raw value.
if (($field->field_alias != 'unknown') && !empty($this->rawOutputOptions[$id])) {
$value = $field->sanitizeValue($field->getValue($row), 'xss_admin');
}
// Otherwise, pass this through the field advancedRender() method.
else {
$value = $field->advancedRender($row);
}
$output[$this->getFieldKeyAlias($id)] = $value;
}
return $output;
}
/**
* Return an alias for a field ID, as set in the options form.
*
* @param string $id
* The field id to lookup an alias for.
*
* @return string
* The matches user entered alias, or the original ID if nothing is found.
*/
public function getFieldKeyAlias($id) {
if (isset($this->replacementAliases[$id])) {
return $this->replacementAliases[$id];
}
return $id;
}
/**
* Extracts a set of option values from a nested options array.
*
* @param string $key
* The key to extract from each array item.
* @param array $options
* The options array to return values from.
*
* @return array
* A regular one dimensional array of values.
*/
protected static function extractFromOptionsArray($key, $options) {
return array_map(function($item) use ($key) {
return isset($item[$key]) ? $item[$key] : NULL;
}, $options);
}
}

View file

@ -0,0 +1,167 @@
<?php
/**
* @file
* Contains \Drupal\rest\Plugin\views\style\Serializer.
*/
namespace Drupal\rest\Plugin\views\style;
use Drupal\Core\Form\FormStateInterface;
use Drupal\views\Plugin\CacheablePluginInterface;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Plugin\views\style\StylePluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* The style plugin for serialized output formats.
*
* @ingroup views_style_plugins
*
* @ViewsStyle(
* id = "serializer",
* title = @Translation("Serializer"),
* help = @Translation("Serializes views row data using the Serializer component."),
* display_types = {"data"}
* )
*/
class Serializer extends StylePluginBase implements CacheablePluginInterface {
/**
* Overrides \Drupal\views\Plugin\views\style\StylePluginBase::$usesRowPlugin.
*/
protected $usesRowPlugin = TRUE;
/**
* Overrides Drupal\views\Plugin\views\style\StylePluginBase::$usesFields.
*/
protected $usesGrouping = FALSE;
/**
* The serializer which serializes the views result.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* The available serialization formats.
*
* @var array
*/
protected $formats = array();
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('serializer'),
$container->getParameter('serializer.formats')
);
}
/**
* Constructs a Plugin object.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, SerializerInterface $serializer, array $serializer_formats) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->definition = $plugin_definition + $configuration;
$this->serializer = $serializer;
$this->formats = $serializer_formats;
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['formats'] = array('default' => array());
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
parent::buildOptionsForm($form, $form_state);
$form['formats'] = array(
'#type' => 'checkboxes',
'#title' => $this->t('Accepted request formats'),
'#description' => $this->t('Request formats that will be allowed in responses. If none are selected all formats will be allowed.'),
'#options' => array_combine($this->formats, $this->formats),
'#default_value' => $this->options['formats'],
);
}
/**
* {@inheritdoc}
*/
public function submitOptionsForm(&$form, FormStateInterface $form_state) {
parent::submitOptionsForm($form, $form_state);
$formats = $form_state->getValue(array('style_options', 'formats'));
$form_state->setValue(array('style_options', 'formats'), array_filter($formats));
}
/**
* {@inheritdoc}
*/
public function render() {
$rows = array();
// If the Data Entity row plugin is used, this will be an array of entities
// which will pass through Serializer to one of the registered Normalizers,
// which will transform it to arrays/scalars. If the Data field row plugin
// is used, $rows will not contain objects and will pass directly to the
// Encoder.
foreach ($this->view->result as $row) {
$rows[] = $this->view->rowPlugin->render($row);
}
// Get the content type configured in the display or fallback to the
// default.
if ((empty($this->view->live_preview))) {
$content_type = $this->displayHandler->getContentType();
}
else {
$content_type = !empty($this->options['formats']) ? reset($this->options['formats']) : 'json';
}
return $this->serializer->serialize($rows, $content_type);
}
/**
* Gets a list of all available formats that can be requested.
*
* This will return the configured formats, or all formats if none have been
* selected.
*
* @return array
* An array of formats.
*/
public function getFormats() {
return $this->options['formats'];
}
/**
* {@inheritdoc}
*/
public function isCacheable() {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return ['request_format'];
}
}

View file

@ -0,0 +1,126 @@
<?php
/**
* @file
* Contains \Drupal\rest\RequestHandler.
*/
namespace Drupal\rest;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
* Acts as intermediate request forwarder for resource plugins.
*/
class RequestHandler implements ContainerAwareInterface {
use ContainerAwareTrait;
/**
* Handles a web API request.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response object.
*/
public function handle(RouteMatchInterface $route_match, Request $request) {
$plugin = $route_match->getRouteObject()->getDefault('_plugin');
$method = strtolower($request->getMethod());
$resource = $this->container
->get('plugin.manager.rest')
->getInstance(array('id' => $plugin));
// Deserialize incoming data if available.
$serializer = $this->container->get('serializer');
$received = $request->getContent();
$unserialized = NULL;
if (!empty($received)) {
$format = $request->getContentType();
// Only allow serialization formats that are explicitly configured. If no
// formats are configured allow all and hope that the serializer knows the
// format. If the serializer cannot handle it an exception will be thrown
// that bubbles up to the client.
$config = $this->container->get('config.factory')->get('rest.settings')->get('resources');
$method_settings = $config[$plugin][$request->getMethod()];
if (empty($method_settings['supported_formats']) || in_array($format, $method_settings['supported_formats'])) {
$definition = $resource->getPluginDefinition();
$class = $definition['serialization_class'];
try {
$unserialized = $serializer->deserialize($received, $class, $format, array('request_method' => $method));
}
catch (UnexpectedValueException $e) {
$error['error'] = $e->getMessage();
$content = $serializer->serialize($error, $format);
return new Response($content, 400, array('Content-Type' => $request->getMimeType($format)));
}
}
else {
throw new UnsupportedMediaTypeHttpException();
}
}
// Determine the request parameters that should be passed to the resource
// plugin.
$route_parameters = $route_match->getParameters();
$parameters = array();
// Filter out all internal parameters starting with "_".
foreach ($route_parameters as $key => $parameter) {
if ($key{0} !== '_') {
$parameters[] = $parameter;
}
}
// Invoke the operation on the resource plugin.
// All REST routes are restricted to exactly one format, so instead of
// parsing it out of the Accept headers again, we can simply retrieve the
// format requirement. If there is no format associated, just pick JSON.
$format = $route_match->getRouteObject()->getRequirement('_format') ?: 'json';
try {
$response = call_user_func_array(array($resource, $method), array_merge($parameters, array($unserialized, $request)));
}
catch (HttpException $e) {
$error['error'] = $e->getMessage();
$content = $serializer->serialize($error, $format);
// Add the default content type, but only if the headers from the
// exception have not specified it already.
$headers = $e->getHeaders() + array('Content-Type' => $request->getMimeType($format));
return new Response($content, $e->getStatusCode(), $headers);
}
// Serialize the outgoing data for the response, if available.
$data = $response->getResponseData();
if ($data != NULL) {
$output = $serializer->serialize($data, $format);
$response->setContent($output);
$response->headers->set('Content-Type', $request->getMimeType($format));
// Add rest settings config's cache tags.
$response->addCacheableDependency($this->container->get('config.factory')->get('rest.settings'));
}
return $response;
}
/**
* Generates a CSRF protecting session token.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response object.
*/
public function csrfToken() {
return new Response(\Drupal::csrfToken()->get('rest'), 200, array('Content-Type' => 'text/plain'));
}
}

View file

@ -0,0 +1,57 @@
<?php
/**
* @file
* Contains \Drupal\rest\ResourceResponse.
*/
namespace Drupal\rest;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Cache\CacheableResponseTrait;
use Symfony\Component\HttpFoundation\Response;
/**
* Contains data for serialization before sending the response.
*
* We do not want to abuse the $content property on the Response class to store
* our response data. $content implies that the provided data must either be a
* string or an object with a __toString() method, which is not a requirement
* for data used here.
*/
class ResourceResponse extends Response implements CacheableResponseInterface {
use CacheableResponseTrait;
/**
* 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;
}
}

View file

@ -0,0 +1,71 @@
<?php
/**
* @file
* Contains \Drupal\rest\RestPermissions.
*/
namespace Drupal\rest;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\rest\Plugin\Type\ResourcePluginManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides rest module permissions.
*/
class RestPermissions implements ContainerInjectionInterface {
/**
* The rest resource plugin manager.
*
* @var \Drupal\rest\Plugin\Type\ResourcePluginManager
*/
protected $restPluginManager;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* Constructs a new RestPermissions instance.
*
* @param \Drupal\rest\Plugin\Type\ResourcePluginManager $rest_plugin_manager
* The rest resource plugin manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(ResourcePluginManager $rest_plugin_manager, ConfigFactoryInterface $config_factory) {
$this->restPluginManager = $rest_plugin_manager;
$this->configFactory = $config_factory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('plugin.manager.rest'), $container->get('config.factory'));
}
/**
* Returns an array of REST permissions.
*
* @return array
*/
public function permissions() {
$permissions = [];
$resources = $this->configFactory->get('rest.settings')->get('resources');
if ($resources && $enabled = array_intersect_key($this->restPluginManager->getDefinitions(), $resources)) {
foreach ($enabled as $key => $resource) {
$plugin = $this->restPluginManager->getInstance(['id' => $key]);
$permissions = array_merge($permissions, $plugin->permissions());
}
}
return $permissions;
}
}

View file

@ -0,0 +1,112 @@
<?php
/**
* @file
* Contains \Drupal\rest\Routing\ResourceRoutes.
*/
namespace Drupal\rest\Routing;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Routing\RouteSubscriberBase;
use Drupal\rest\Plugin\Type\ResourcePluginManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouteCollection;
/**
* Subscriber for REST-style routes.
*/
class ResourceRoutes extends RouteSubscriberBase {
/**
* The plugin manager for REST plugins.
*
* @var \Drupal\rest\Plugin\Type\ResourcePluginManager
*/
protected $manager;
/**
* The Drupal configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $config;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Constructs a RouteSubscriber object.
*
* @param \Drupal\rest\Plugin\Type\ResourcePluginManager $manager
* The resource plugin manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config
* The configuration factory holding resource settings.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
*/
public function __construct(ResourcePluginManager $manager, ConfigFactoryInterface $config, LoggerInterface $logger) {
$this->manager = $manager;
$this->config = $config;
$this->logger = $logger;
}
/**
* Alters existing routes for a specific collection.
*
* @param \Symfony\Component\Routing\RouteCollection $collection
* The route collection for adding routes.
* @return array
*/
protected function alterRoutes(RouteCollection $collection) {
$routes = array();
$enabled_resources = $this->config->get('rest.settings')->get('resources') ?: array();
// Iterate over all enabled resource plugins.
foreach ($enabled_resources as $id => $enabled_methods) {
$plugin = $this->manager->getInstance(array('id' => $id));
foreach ($plugin->routes() as $name => $route) {
// @todo: Are multiple methods possible here?
$methods = $route->getMethods();
// Only expose routes where the method is enabled in the configuration.
if ($methods && ($method = $methods[0]) && $method && isset($enabled_methods[$method])) {
$route->setRequirement('_access_rest_csrf', 'TRUE');
// Check that authentication providers are defined.
if (empty($enabled_methods[$method]['supported_auth']) || !is_array($enabled_methods[$method]['supported_auth'])) {
$this->logger->error('At least one authentication provider must be defined for resource @id', array(':id' => $id));
continue;
}
// Check that formats are defined.
if (empty($enabled_methods[$method]['supported_formats']) || !is_array($enabled_methods[$method]['supported_formats'])) {
$this->logger->error('At least one format must be defined for resource @id', array(':id' => $id));
continue;
}
// If the route has a format requirement, then verify that the
// resource has it.
$format_requirement = $route->getRequirement('_format');
if ($format_requirement && !in_array($format_requirement, $enabled_methods[$method]['supported_formats'])) {
continue;
}
// The configuration seems legit at this point, so we set the
// authentication provider and add the route.
$route->setOption('_auth', $enabled_methods[$method]['supported_auth']);
$routes["rest.$name"] = $route;
$collection->add("rest.$name", $route);
}
}
}
}
}

View file

@ -0,0 +1,110 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\AuthTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Core\Url;
use Drupal\rest\Tests\RESTTestBase;
/**
* Tests authentication provider restrictions.
*
* @group rest
*/
class AuthTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('basic_auth', 'hal', 'rest', 'entity_test', 'comment');
/**
* Tests reading from an authenticated resource.
*/
public function testRead() {
$entity_type = 'entity_test';
// Enable a test resource through GET method and basic HTTP authentication.
$this->enableService('entity:' . $entity_type, 'GET', NULL, array('basic_auth'));
// Create an entity programmatically.
$entity = $this->entityCreate($entity_type);
$entity->save();
// Try to read the resource as an anonymous user, which should not work.
$this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
$this->assertResponse('401', 'HTTP response code is 401 when the request is not authenticated and the user is anonymous.');
$this->assertRaw(json_encode(['message' => 'A fatal error occurred: No authentication credentials provided.']));
// Ensure that cURL settings/headers aren't carried over to next request.
unset($this->curlHandle);
// Create a user account that has the required permissions to read
// resources via the REST API, but the request is authenticated
// with session cookies.
$permissions = $this->entityPermissions($entity_type, 'view');
$permissions[] = 'restful get entity:' . $entity_type;
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
// Try to read the resource with session cookie authentication, which is
// not enabled and should not work.
$this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
$this->assertResponse('403', 'HTTP response code is 403 when the request was authenticated by the wrong authentication provider.');
// Ensure that cURL settings/headers aren't carried over to next request.
unset($this->curlHandle);
// Now read it with the Basic authentication which is enabled and should
// work.
$this->basicAuthGet($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), $account->getUsername(), $account->pass_raw);
$this->assertResponse('200', 'HTTP response code is 200 for successfully authenticated requests.');
$this->curlClose();
}
/**
* Performs a HTTP request with Basic authentication.
*
* We do not use \Drupal\simpletest\WebTestBase::drupalGet because we need to
* set curl settings for basic authentication.
*
* @param \Drupal\Core\Url $url
* An Url object.
* @param string $username
* The user name to authenticate with.
* @param string $password
* The password.
* @param string $mime_type
* The MIME type for the Accept header.
*
* @return string
* Curl output.
*/
protected function basicAuthGet(Url $url, $username, $password, $mime_type = NULL) {
if (!isset($mime_type)) {
$mime_type = $this->defaultMimeType;
}
$out = $this->curlExec(
array(
CURLOPT_HTTPGET => TRUE,
CURLOPT_URL => $url->setAbsolute()->toString(),
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $username . ':' . $password,
CURLOPT_HTTPHEADER => array('Accept: ' . $mime_type),
)
);
$this->verbose('GET request to: ' . $url->toString() .
'<hr />' . $out);
return $out;
}
}

View file

@ -0,0 +1,413 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\CreateTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Entity\EntityInterface;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\node\Entity\Node;
use Drupal\user\Entity\User;
/**
* Tests the creation of resources.
*
* @group rest
*/
class CreateTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test');
/**
* The 'serializer' service.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
protected function setUp() {
parent::setUp();
// Get the 'serializer' service.
$this->serializer = $this->container->get('serializer');
}
/**
* Try to create a resource which is not REST API enabled.
*/
public function testCreateResourceRestApiNotEnabled() {
$entity_type = 'entity_test';
// Enables the REST service for a specific entity type.
$this->enableService('entity:' . $entity_type, 'POST');
// Get the necessary user permissions to create the current entity type.
$permissions = $this->entityPermissions($entity_type, 'create');
// POST method must be allowed for the current entity type.
$permissions[] = 'restful post entity:' . $entity_type;
// Create the user.
$account = $this->drupalCreateUser($permissions);
// Populate some entity properties before create the entity.
$entity_values = $this->entityValues($entity_type);
$entity = EntityTest::create($entity_values);
// Serialize the entity before the POST request.
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
// Disable all resource types.
$this->enableService(FALSE);
$this->drupalLogin($account);
// POST request to create the current entity. GET request for CSRF token
// is included into the httpRequest() method.
$this->httpRequest('entity/entity_test', 'POST', $serialized, $this->defaultMimeType);
// The resource is not enabled. So, we receive a 'not found' response.
$this->assertResponse(404);
$this->assertFalse(EntityTest::loadMultiple(), 'No entity has been created in the database.');
}
/**
* Ensure that an entity cannot be created without the restful permission.
*/
public function testCreateWithoutPermission() {
$entity_type = 'entity_test';
// Enables the REST service for 'entity_test' entity type.
$this->enableService('entity:' . $entity_type, 'POST');
$permissions = $this->entityPermissions($entity_type, 'create');
// Create a user without the 'restful post entity:entity_test permission.
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
// Populate some entity properties before create the entity.
$entity_values = $this->entityValues($entity_type);
$entity = EntityTest::create($entity_values);
// Serialize the entity before the POST request.
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
// Create the entity over the REST API.
$this->httpRequest('entity/' . $entity_type, 'POST', $serialized, $this->defaultMimeType);
$this->assertResponse(403);
$this->assertFalse(EntityTest::loadMultiple(), 'No entity has been created in the database.');
}
/**
* Tests valid and invalid create requests for 'entity_test' entity type.
*/
public function testCreateEntityTest() {
$entity_type = 'entity_test';
// Enables the REST service for 'entity_test' entity type.
$this->enableService('entity:' . $entity_type, 'POST');
// Create two accounts with the required permissions to create resources.
// The second one has administrative permissions.
$accounts = $this->createAccountPerEntity($entity_type);
// Verify create requests per user.
foreach ($accounts as $key => $account) {
$this->drupalLogin($account);
// Populate some entity properties before create the entity.
$entity_values = $this->entityValues($entity_type);
$entity = EntityTest::create($entity_values);
// Serialize the entity before the POST request.
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
// Create the entity over the REST API.
$this->assertCreateEntityOverRestApi($entity_type, $serialized);
// Get the entity ID from the location header and try to read it from the
// database.
$this->assertReadEntityIdFromHeaderAndDb($entity_type, $entity, $entity_values);
// Try to create an entity with an access protected field.
// @see entity_test_entity_field_access()
$normalized = $this->serializer->normalize($entity, $this->defaultFormat, ['account' => $account]);
$normalized['field_test_text'][0]['value'] = 'no access value';
$this->httpRequest('entity/' . $entity_type, 'POST', $this->serializer->serialize($normalized, $this->defaultFormat, ['account' => $account]), $this->defaultMimeType);
$this->assertResponse(403);
$this->assertFalse(EntityTest::loadMultiple(), 'No entity has been created in the database.');
// Try to create a field with a text format this user has no access to.
$entity->field_test_text->value = $entity_values['field_test_text'][0]['value'];
$entity->field_test_text->format = 'full_html';
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
$this->httpRequest('entity/' . $entity_type, 'POST', $serialized, $this->defaultMimeType);
// The value selected is not a valid choice because the format must be
// 'plain_txt'.
$this->assertResponse(422);
$this->assertFalse(EntityTest::loadMultiple(), 'No entity has been created in the database.');
// Restore the valid test value.
$entity->field_test_text->format = 'plain_text';
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
// Try to send invalid data that cannot be correctly deserialized.
$this->assertCreateEntityInvalidData($entity_type);
// Try to send no data at all, which does not make sense on POST requests.
$this->assertCreateEntityNoData($entity_type);
// Try to send invalid data to trigger the entity validation constraints.
// Send a UUID that is too long.
$this->assertCreateEntityInvalidSerialized($entity, $entity_type);
// Try to create an entity without proper permissions.
$this->assertCreateEntityWithoutProperPermissions($entity_type, $serialized, ['account' => $account]);
}
}
/**
* Tests several valid and invalid create requests for 'node' entity type.
*/
public function testCreateNode() {
$entity_type = 'node';
// Enables the REST service for 'node' entity type.
$this->enableService('entity:' . $entity_type, 'POST');
// Create two accounts that have the required permissions to create
// resources. The second one has administrative permissions.
$accounts = $this->createAccountPerEntity($entity_type);
// Verify create requests per user.
foreach ($accounts as $key => $account) {
$this->drupalLogin($account);
// Populate some entity properties before create the entity.
$entity_values = $this->entityValues($entity_type);
$entity = Node::create($entity_values);
// Verify that user cannot create content when trying to write to fields
// where it is not possible.
if (!$account->hasPermission('administer nodes')) {
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
$this->httpRequest('entity/' . $entity_type, 'POST', $serialized, $this->defaultMimeType);
$this->assertResponse(403);
// Remove fields where non-administrative users cannot write.
$entity = $this->removeNodeFieldsForNonAdminUsers($entity);
}
else {
// Changed and revision_timestamp fields can never be added.
unset($entity->changed);
unset($entity->revision_timestamp);
}
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
// Create the entity over the REST API.
$this->assertCreateEntityOverRestApi($entity_type, $serialized);
// Get the new entity ID from the location header and try to read it from
// the database.
$this->assertReadEntityIdFromHeaderAndDb($entity_type, $entity, $entity_values);
// Try to send invalid data that cannot be correctly deserialized.
$this->assertCreateEntityInvalidData($entity_type);
// Try to send no data at all, which does not make sense on POST requests.
$this->assertCreateEntityNoData($entity_type);
// Try to send invalid data to trigger the entity validation constraints. Send a UUID that is too long.
$this->assertCreateEntityInvalidSerialized($entity, $entity_type);
// Try to create an entity without proper permissions.
$this->assertCreateEntityWithoutProperPermissions($entity_type, $serialized);
}
}
/**
* Tests several valid and invalid create requests for 'user' entity type.
*/
public function testCreateUser() {
$entity_type = 'user';
// Enables the REST service for 'user' entity type.
$this->enableService('entity:' . $entity_type, 'POST');
// Create two accounts that have the required permissions to create
// resources. The second one has administrative permissions.
$accounts = $this->createAccountPerEntity($entity_type);
foreach ($accounts as $key => $account) {
$this->drupalLogin($account);
$entity_values = $this->entityValues($entity_type);
$entity = User::create($entity_values);
// Verify that only administrative users can create users.
if (!$account->hasPermission('administer users')) {
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
$this->httpRequest('entity/' . $entity_type, 'POST', $serialized, $this->defaultMimeType);
$this->assertResponse(403);
continue;
}
// Changed field can never be added.
unset($entity->changed);
$serialized = $this->serializer->serialize($entity, $this->defaultFormat, ['account' => $account]);
// Create the entity over the REST API.
$this->assertCreateEntityOverRestApi($entity_type, $serialized);
// Get the new entity ID from the location header and try to read it from
// the database.
$this->assertReadEntityIdFromHeaderAndDb($entity_type, $entity, $entity_values);
// Try to send invalid data that cannot be correctly deserialized.
$this->assertCreateEntityInvalidData($entity_type);
// Try to send no data at all, which does not make sense on POST requests.
$this->assertCreateEntityNoData($entity_type);
// Try to send invalid data to trigger the entity validation constraints.
// Send a UUID that is too long.
$this->assertCreateEntityInvalidSerialized($entity, $entity_type);
}
}
/**
* Creates user accounts that have the required permissions to create
* resources via the REST API. The second one has administrative permissions.
*
* @param string $entity_type
* Entity type needed to apply user permissions.
* @return array
* An array that contains user accounts.
*/
public function createAccountPerEntity($entity_type) {
$accounts = array();
// Get the necessary user permissions for the current $entity_type creation.
$permissions = $this->entityPermissions($entity_type, 'create');
// POST method must be allowed for the current entity type.
$permissions[] = 'restful post entity:' . $entity_type;
// Create user without administrative permissions.
$accounts[] = $this->drupalCreateUser($permissions);
// Add administrative permissions for nodes and users.
$permissions[] = 'administer nodes';
$permissions[] = 'administer users';
// Create an administrative user.
$accounts[] = $this->drupalCreateUser($permissions);
return $accounts;
}
/**
* Creates the entity over the REST API.
*
* @param string $entity_type
* The type of the entity that should be created.
* @param string $serialized
* The body for the POST request.
*/
public function assertCreateEntityOverRestApi($entity_type, $serialized = NULL) {
// Note: this will fail with PHP 5.6 when always_populate_raw_post_data is
// set to something other than -1. See https://www.drupal.org/node/2456025.
$this->httpRequest('entity/' . $entity_type, 'POST', $serialized, $this->defaultMimeType);
$this->assertResponse(201);
}
/**
* Gets the new entity ID from the location header and tries to read it from
* the database.
*
* @param string $entity_type
* Entity type we need to load the entity from DB.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity we want to check that was inserted correctly.
* @param array $entity_values
* The values of $entity.
*/
public function assertReadEntityIdFromHeaderAndDb($entity_type, EntityInterface $entity, array $entity_values = array()) {
// Get the location from the HTTP response header.
$location_url = $this->drupalGetHeader('location');
$url_parts = explode('/', $location_url);
$id = end($url_parts);
// Get the entity using the ID found.
$loaded_entity = \Drupal::entityManager()->getStorage($entity_type)->load($id);
$this->assertNotIdentical(FALSE, $loaded_entity, 'The new ' . $entity_type . ' was found in the database.');
$this->assertEqual($entity->uuid(), $loaded_entity->uuid(), 'UUID of created entity is correct.');
// Verify that the field values sent and received from DB are the same.
foreach ($entity_values as $property => $value) {
$actual_value = $loaded_entity->get($property)->value;
$send_value = $entity->get($property)->value;
$this->assertEqual($send_value, $actual_value, 'Created property ' . $property . ' expected: ' . $send_value . ', actual: ' . $actual_value);
}
// Delete the entity loaded from DB.
$loaded_entity->delete();
}
/**
* Try to send invalid data that cannot be correctly deserialized.
*
* @param string $entity_type
* The type of the entity that should be created.
*/
public function assertCreateEntityInvalidData($entity_type) {
$this->httpRequest('entity/' . $entity_type, 'POST', 'kaboom!', $this->defaultMimeType);
$this->assertResponse(400);
}
/**
* Try to send no data at all, which does not make sense on POST requests.
*
* @param string $entity_type
* The type of the entity that should be created.
*/
public function assertCreateEntityNoData($entity_type) {
$this->httpRequest('entity/' . $entity_type, 'POST', NULL, $this->defaultMimeType);
$this->assertResponse(400);
}
/**
* Send an invalid UUID to trigger the entity validation.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity we want to check that was inserted correctly.
* @param string $entity_type
* The type of the entity that should be created.
* @param array $context
* Options normalizers/encoders have access to.
*/
public function assertCreateEntityInvalidSerialized(EntityInterface $entity, $entity_type, array $context = array()) {
// Add a UUID that is too long.
$entity->set('uuid', $this->randomMachineName(129));
$invalid_serialized = $this->serializer->serialize($entity, $this->defaultFormat, $context);
$response = $this->httpRequest('entity/' . $entity_type, 'POST', $invalid_serialized, $this->defaultMimeType);
// Unprocessable Entity as response.
$this->assertResponse(422);
// Verify that the text of the response is correct.
$error = Json::decode($response);
$this->assertEqual($error['error'], "Unprocessable Entity: validation failed.\nuuid.0.value: <em class=\"placeholder\">UUID</em>: may not be longer than 128 characters.\n");
}
/**
* Try to create an entity without proper permissions.
*
* @param string $entity_type
* The type of the entity that should be created.
* @param string $serialized
* The body for the POST request.
*/
public function assertCreateEntityWithoutProperPermissions($entity_type, $serialized = NULL) {
$this->drupalLogout();
$this->httpRequest('entity/' . $entity_type, 'POST', $serialized, $this->defaultMimeType);
// Forbidden Error as response.
$this->assertResponse(403);
$this->assertFalse(\Drupal::entityManager()->getStorage($entity_type)->loadMultiple(), 'No entity has been created in the database.');
}
}

View file

@ -0,0 +1,119 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\CsrfTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Core\Url;
/**
* Tests the CSRF protection.
*
* @group rest
*/
class CsrfTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test', 'basic_auth');
/**
* A testing user account.
*
* @var \Drupal\user\Entity\User
*/
protected $account;
/**
* The serialized entity.
*
* @var string
*/
protected $serialized;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->enableService('entity:' . $this->testEntityType, 'POST', 'hal_json', array('basic_auth', 'cookie'));
// Create a user account that has the required permissions to create
// resources via the REST API.
$permissions = $this->entityPermissions($this->testEntityType, 'create');
$permissions[] = 'restful post entity:' . $this->testEntityType;
$this->account = $this->drupalCreateUser($permissions);
// Serialize an entity to a string to use in the content body of the POST
// request.
$serializer = $this->container->get('serializer');
$entity_values = $this->entityValues($this->testEntityType);
$entity = entity_create($this->testEntityType, $entity_values);
$this->serialized = $serializer->serialize($entity, $this->defaultFormat);
}
/**
* Tests that CSRF check is not triggered for Basic Auth requests.
*/
public function testBasicAuth() {
$curl_options = $this->getCurlOptions();
$curl_options[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
$curl_options[CURLOPT_USERPWD] = $this->account->getUsername() . ':' . $this->account->pass_raw;
$this->curlExec($curl_options);
$this->assertResponse(201);
// Ensure that the entity was created.
$loaded_entity = $this->loadEntityFromLocationHeader($this->drupalGetHeader('location'));
$this->assertTrue($loaded_entity, 'An entity was created in the database');
}
/**
* Tests that CSRF check is triggered for Cookie Auth requests.
*/
public function testCookieAuth() {
$this->drupalLogin($this->account);
$curl_options = $this->getCurlOptions();
// Try to create an entity without the CSRF token.
// Note: this will fail with PHP 5.6 when always_populate_raw_post_data is
// set to something other than -1. See https://www.drupal.org/node/2456025.
$this->curlExec($curl_options);
$this->assertResponse(403);
// Ensure that the entity was not created.
$this->assertFalse(entity_load_multiple($this->testEntityType, NULL, TRUE), 'No entity has been created in the database.');
// Create an entity with the CSRF token.
$token = $this->drupalGet('rest/session/token');
$curl_options[CURLOPT_HTTPHEADER][] = "X-CSRF-Token: $token";
$this->curlExec($curl_options);
$this->assertResponse(201);
// Ensure that the entity was created.
$loaded_entity = $this->loadEntityFromLocationHeader($this->drupalGetHeader('location'));
$this->assertTrue($loaded_entity, 'An entity was created in the database');
}
/**
* Gets the cURL options to create an entity with POST.
*
* @return array
* The array of cURL options.
*/
protected function getCurlOptions() {
return array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_POST => TRUE,
CURLOPT_POSTFIELDS => $this->serialized,
CURLOPT_URL => Url::fromRoute('rest.entity.' . $this->testEntityType . '.POST')->setAbsolute()->toString(),
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array(
"Content-Type: {$this->defaultMimeType}",
),
);
}
}

View file

@ -0,0 +1,80 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\DeleteTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Core\Url;
/**
* Tests the deletion of resources.
*
* @group rest
*/
class DeleteTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test');
/**
* Tests several valid and invalid delete requests on all entity types.
*/
public function testDelete() {
// Define the entity types we want to test.
// @todo expand this test to at least users once their access
// controllers are implemented.
$entity_types = array('entity_test', 'node');
foreach ($entity_types as $entity_type) {
$this->enableService('entity:' . $entity_type, 'DELETE');
// Create a user account that has the required permissions to delete
// resources via the REST API.
$permissions = $this->entityPermissions($entity_type, 'delete');
$permissions[] = 'restful delete entity:' . $entity_type;
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
// Create an entity programmatically.
$entity = $this->entityCreate($entity_type);
$entity->save();
// Delete it over the REST API.
$response = $this->httpRequest($entity->urlInfo(), '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(Url::fromRoute('entity.' . $entity_type . '.canonical', [$entity_type => 9999]), 'DELETE');
$this->assertResponse(404);
$this->assertText('The requested page could not be found.');
// 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->urlInfo(), '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 REST API enabled.
$this->enableService(FALSE);
$account = $this->drupalCreateUser();
$this->drupalLogin($account);
$this->httpRequest($account->urlInfo(), 'DELETE');
$user_storage = $this->container->get('entity.manager')->getStorage('user');
$user_storage->resetCache(array($account->id()));
$user = $user_storage->load($account->id());
$this->assertEqual($account->id(), $user->id(), 'User still exists in the database.');
$this->assertResponse(405);
}
}

View file

@ -0,0 +1,93 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\NodeTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Core\Url;
use Drupal\rest\Tests\RESTTestBase;
/**
* Tests special cases for node entities.
*
* @group rest
*/
class NodeTest extends RESTTestBase {
/**
* Modules to install.
*
* Ensure that the node resource works with comment module enabled.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'comment');
/**
* Enables node specific REST API configuration and authentication.
*
* @param string $method
* The HTTP method to be tested.
* @param string $operation
* The operation, one of 'view', 'create', 'update' or 'delete'.
*/
protected function enableNodeConfiguration($method, $operation) {
$this->enableService('entity:node', $method);
$permissions = $this->entityPermissions('node', $operation);
$permissions[] = 'restful ' . strtolower($method) . ' entity:node';
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
}
/**
* Performs various tests on nodes and their REST API.
*/
public function testNodes() {
$node_storage = $this->container->get('entity.manager')->getStorage('node');
$this->enableNodeConfiguration('GET', 'view');
$node = $this->entityCreate('node');
$node->save();
$this->httpRequest($node->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
$this->assertResponse(200);
$this->assertHeader('Content-type', $this->defaultMimeType);
// Also check that JSON works and the routing system selects the correct
// REST route.
$this->enableService('entity:node', 'GET', 'json');
$this->httpRequest($node->urlInfo()->setRouteParameter('_format', 'json'), 'GET');
$this->assertResponse(200);
$this->assertHeader('Content-type', 'application/json');
// Check that a simple PATCH update to the node title works as expected.
$this->enableNodeConfiguration('PATCH', 'update');
// Create a PATCH request body that only updates the title field.
$new_title = $this->randomString();
$data = array(
'_links' => array(
'type' => array(
'href' => Url::fromUri('base:rest/type/node/resttest', array('absolute' => TRUE))->toString(),
),
),
'title' => array(
array(
'value' => $new_title,
),
),
);
$serialized = $this->container->get('serializer')->serialize($data, $this->defaultFormat);
$this->httpRequest($node->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(204);
// Reload the node from the DB and check if the title was correctly updated.
$node_storage->resetCache(array($node->id()));
$updated_node = $node_storage->load($node->id());
$this->assertEqual($updated_node->getTitle(), $new_title);
// Make sure that the UUID of the node has not changed.
$this->assertEqual($node->get('uuid')->getValue(), $updated_node->get('uuid')->getValue(), 'UUID was not changed.');
}
}

View file

@ -0,0 +1,61 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\PageCacheTest.
*/
namespace Drupal\rest\Tests;
/**
* Tests page caching for REST GET requests.
*
* @group rest
*/
class PageCacheTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test');
/**
* Tests that configuration changes also clear the page cache.
*/
public function testConfigChangePageCache() {
$this->enableService('entity:entity_test', 'GET');
// Allow anonymous users to issue GET requests.
$permissions = $this->entityPermissions('entity_test', 'view');
$permissions[] = 'restful get entity:entity_test';
user_role_grant_permissions('anonymous', $permissions);
// Create an entity programmatically.
$entity = $this->entityCreate('entity_test');
$entity->save();
// Read it over the REST API.
$this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET', NULL, $this->defaultMimeType);
$this->assertResponse(200, 'HTTP response code is correct.');
$this->assertHeader('x-drupal-cache', 'MISS');
$this->assertCacheTag('config:rest.settings');
$this->assertCacheTag('entity_test:1');
// Read it again, should be page-cached now.
$this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET', NULL, $this->defaultMimeType);
$this->assertResponse(200, 'HTTP response code is correct.');
$this->assertHeader('x-drupal-cache', 'HIT');
$this->assertCacheTag('config:rest.settings');
$this->assertCacheTag('entity_test:1');
// Trigger a config save which should clear the page cache, so we should get
// a cache miss now for the same request.
$this->config('rest.settings')->save();
$this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET', NULL, $this->defaultMimeType);
$this->assertResponse(200, 'HTTP response code is correct.');
$this->assertHeader('x-drupal-cache', 'MISS');
$this->assertCacheTag('config:rest.settings');
$this->assertCacheTag('entity_test:1');
}
}

View file

@ -0,0 +1,364 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\RESTTestBase.
*/
namespace Drupal\rest\Tests;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
use Drupal\simpletest\WebTestBase;
use Drupal\user\UserInterface;
/**
* Test helper class that provides a REST client method to send HTTP requests.
*/
abstract class RESTTestBase extends WebTestBase {
/**
* The default serialization format to use for testing REST operations.
*
* @var string
*/
protected $defaultFormat;
/**
* The default MIME type to use for testing REST operations.
*
* @var string
*/
protected $defaultMimeType;
/**
* The entity type to use for testing.
*
* @var string
*/
protected $testEntityType = 'entity_test';
/**
* The default authentication provider to use for testing REST operations.
*
* @var array
*/
protected $defaultAuth;
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('rest', 'entity_test', 'node');
protected function setUp() {
parent::setUp();
$this->defaultFormat = 'hal_json';
$this->defaultMimeType = 'application/hal+json';
$this->defaultAuth = array('cookie');
// Create a test content type for node testing.
$this->drupalCreateContentType(array('name' => 'resttest', 'type' => 'resttest'));
}
/**
* Helper function to issue a HTTP request with simpletest's cURL.
*
* @param string|\Drupal\Core\Url $url
* A Url object or system path.
* @param string $method
* HTTP method, one of GET, POST, PUT or DELETE.
* @param string $body
* The body for POST and PUT.
* @param string $mime_type
* The MIME type of the transmitted content.
*
* @return string
* The content returned from the request.
*/
protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL) {
if (!isset($mime_type)) {
$mime_type = $this->defaultMimeType;
}
if (!in_array($method, array('GET', 'HEAD', 'OPTIONS', 'TRACE'))) {
// GET the CSRF token first for writing requests.
$token = $this->drupalGet('rest/session/token');
}
$url = $this->buildUrl($url);
switch ($method) {
case 'GET':
// Set query if there are additional GET parameters.
$curl_options = array(
CURLOPT_HTTPGET => TRUE,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_URL => $url,
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array('Accept: ' . $mime_type),
);
break;
case 'POST':
$curl_options = array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_POST => TRUE,
CURLOPT_POSTFIELDS => $body,
CURLOPT_URL => $url,
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array(
'Content-Type: ' . $mime_type,
'X-CSRF-Token: ' . $token,
),
);
break;
case 'PUT':
$curl_options = array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $body,
CURLOPT_URL => $url,
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array(
'Content-Type: ' . $mime_type,
'X-CSRF-Token: ' . $token,
),
);
break;
case 'PATCH':
$curl_options = array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_POSTFIELDS => $body,
CURLOPT_URL => $url,
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array(
'Content-Type: ' . $mime_type,
'X-CSRF-Token: ' . $token,
),
);
break;
case 'DELETE':
$curl_options = array(
CURLOPT_HTTPGET => FALSE,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_URL => $url,
CURLOPT_NOBODY => FALSE,
CURLOPT_HTTPHEADER => array('X-CSRF-Token: ' . $token),
);
break;
}
$response = $this->curlExec($curl_options);
// Ensure that any changes to variables in the other thread are picked up.
$this->refreshVariables();
$headers = $this->drupalGetHeaders();
$this->verbose($method . ' request to: ' . $url .
'<hr />Code: ' . curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE) .
'<hr />Response headers: ' . nl2br(print_r($headers, TRUE)) .
'<hr />Response body: ' . $response);
return $response;
}
/**
* Creates entity objects based on their types.
*
* @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) {
return entity_create($entity_type, $this->entityValues($entity_type));
}
/**
* Provides an array of suitable property values for an 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.
*
* @return array
* An array of values keyed by property name.
*/
protected function entityValues($entity_type) {
switch ($entity_type) {
case 'entity_test':
return array(
'name' => $this->randomMachineName(),
'user_id' => 1,
'field_test_text' => array(0 => array(
'value' => $this->randomString(),
'format' => 'plain_text',
)),
);
case 'node':
return array('title' => $this->randomString(), 'type' => 'resttest');
case 'node_type':
return array(
'type' => 'article',
'name' => $this->randomMachineName(),
);
case 'user':
return array('name' => $this->randomMachineName());
default:
return array();
}
}
/**
* Enables the REST service interface for a specific entity type.
*
* @param string|FALSE $resource_type
* The resource type that should get REST API enabled or FALSE to disable all
* resource types.
* @param string $method
* The HTTP method to enable, e.g. GET, POST etc.
* @param string $format
* (Optional) The serialization format, e.g. hal_json.
* @param array $auth
* (Optional) The list of valid authentication methods.
*/
protected function enableService($resource_type, $method = 'GET', $format = NULL, $auth = NULL) {
// Enable REST API for this entity type.
$config = $this->config('rest.settings');
$settings = array();
if ($resource_type) {
if ($format == NULL) {
$format = $this->defaultFormat;
}
$settings[$resource_type][$method]['supported_formats'][] = $format;
if ($auth == NULL) {
$auth = $this->defaultAuth;
}
$settings[$resource_type][$method]['supported_auth'] = $auth;
}
$config->set('resources', $settings);
$config->save();
$this->rebuildCache();
}
/**
* Rebuilds routing caches.
*/
protected function rebuildCache() {
// Rebuild routing cache, so that the REST API paths are available.
$this->container->get('router.builder')->rebuild();
}
/**
* {@inheritdoc}
*
* This method is overridden to deal with a cURL quirk: the usage of
* CURLOPT_CUSTOMREQUEST cannot be unset on the cURL handle, so we need to
* override it every time it is omitted.
*/
protected function curlExec($curl_options, $redirect = FALSE) {
if (!isset($curl_options[CURLOPT_CUSTOMREQUEST])) {
if (!empty($curl_options[CURLOPT_HTTPGET])) {
$curl_options[CURLOPT_CUSTOMREQUEST] = 'GET';
}
if (!empty($curl_options[CURLOPT_POST])) {
$curl_options[CURLOPT_CUSTOMREQUEST] = 'POST';
}
}
return parent::curlExec($curl_options, $redirect);
}
/**
* Provides the necessary user permissions for entity operations.
*
* @param string $entity_type
* The entity type.
* @param string $operation
* The operation, one of 'view', 'create', 'update' or 'delete'.
*
* @return array
* The set of user permission strings.
*/
protected function entityPermissions($entity_type, $operation) {
switch ($entity_type) {
case 'entity_test':
switch ($operation) {
case 'view':
return array('view test entity');
case 'create':
case 'update':
case 'delete':
return array('administer entity_test content');
}
case 'node':
switch ($operation) {
case 'view':
return array('access content');
case 'create':
return array('create resttest content');
case 'update':
return array('edit any resttest content');
case 'delete':
return array('delete any resttest content');
}
case 'user':
switch ($operation) {
case 'view':
return ['access user profiles'];
default:
return ['administer users'];
}
}
}
/**
* Loads an entity based on the location URL returned in the location header.
*
* @param string $location_url
* The URL returned in the Location header.
*
* @return \Drupal\Core\Entity\Entity|FALSE.
* The entity or FALSE if there is no matching entity.
*/
protected function loadEntityFromLocationHeader($location_url) {
$url_parts = explode('/', $location_url);
$id = end($url_parts);
return entity_load($this->testEntityType, $id);
}
/**
* Remove node fields that can only be written by an admin user.
*
* @param \Drupal\node\NodeInterface $node
* The node to remove fields where non-administrative users cannot write.
*
* @return \Drupal\node\NodeInterface
* The node with removed fields.
*/
protected function removeNodeFieldsForNonAdminUsers(NodeInterface $node) {
$node->set('status', NULL);
$node->set('created', NULL);
$node->set('changed', NULL);
$node->set('promote', NULL);
$node->set('sticky', NULL);
$node->set('revision_timestamp', NULL);
$node->set('uid', NULL);
return $node;
}
}

View file

@ -0,0 +1,124 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\ReadTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\rest\Tests\RESTTestBase;
/**
* Tests the retrieval of resources.
*
* @group rest
*/
class ReadTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test');
/**
* Tests several valid and invalid read requests on all entity types.
*/
public function testRead() {
// @todo Expand this at least to users.
// Define the entity types we want to test.
$entity_types = array('entity_test', 'node');
foreach ($entity_types as $entity_type) {
$this->enableService('entity:' . $entity_type, 'GET');
// Create a user account that has the required permissions to read
// resources via the REST API.
$permissions = $this->entityPermissions($entity_type, 'view');
$permissions[] = 'restful get entity:' . $entity_type;
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
// Create an entity programmatically.
$entity = $this->entityCreate($entity_type);
$entity->save();
// Read it over the REST API.
$response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), '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');
// Try to read the entity with an unsupported mime format.
$response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', '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');
$this->assertResponse(404);
$path = $entity_type == 'node' ? '/node/{node}' : '/entity_test/{entity_test}';
$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.');
// Make sure that field level access works and that the according field is
// not available in the response. Only applies to entity_test.
// @see entity_test_entity_field_access()
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');
$this->assertResponse(200);
$this->assertHeader('content-type', $this->defaultMimeType);
$data = Json::decode($response);
$this->assertFalse(isset($data['field_test_text']), 'Field access protected field is not visible in the response.');
}
// Try to read an entity without proper permissions.
$this->drupalLogout();
$response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
$this->assertResponse(403);
$this->assertIdentical('{"message":""}', $response);
}
// Try to read a resource which is not REST API enabled.
$account = $this->drupalCreateUser();
$this->drupalLogin($account);
$response = $this->httpRequest($account->urlInfo()->setRouteParameter('_format', $this->defaultFormat), '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
// route, the non-REST route is used, but can't render into
// application/hal+json, so it returns a 406.
$this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.');
$this->assertEqual($response, Json::encode([
'message' => 'Not acceptable',
]));
}
/**
* Tests the resource structure.
*/
public function testResourceStructure() {
// Enable a service with a format restriction but no authentication.
$this->enableService('entity:node', 'GET', 'json');
// Create a user account that has the required permissions to read
// resources via the REST API.
$permissions = $this->entityPermissions('node', 'view');
$permissions[] = 'restful get entity:node';
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
// Create an entity programmatically.
$entity = $this->entityCreate('node');
$entity->save();
// Read it over the REST API.
$response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', 'json'), 'GET');
$this->assertResponse('200', 'HTTP response code is correct.');
}
}

View file

@ -0,0 +1,103 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\ResourceTest.
*/
namespace Drupal\rest\Tests;
/**
* Tests the structure of a REST resource.
*
* @group rest
*/
class ResourceTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test');
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->config = $this->config('rest.settings');
// Create an entity programmatically.
$this->entity = $this->entityCreate('entity_test');
$this->entity->save();
}
/**
* Tests that a resource without formats cannot be enabled.
*/
public function testFormats() {
$settings = array(
'entity:entity_test' => array(
'GET' => array(
'supported_auth' => array(
'basic_auth',
),
),
),
);
// Attempt to enable the resource.
$this->config->set('resources', $settings);
$this->config->save();
$this->rebuildCache();
// Verify that accessing the resource returns 406.
$response = $this->httpRequest($this->entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), '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
// route, the non-REST route is used, but can't render into
// application/hal+json, so it returns a 406.
$this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.');
$this->curlClose();
}
/**
* Tests that a resource without authentication cannot be enabled.
*/
public function testAuthentication() {
$settings = array(
'entity:entity_test' => array(
'GET' => array(
'supported_formats' => array(
'hal_json',
),
),
),
);
// Attempt to enable the resource.
$this->config->set('resources', $settings);
$this->config->save();
$this->rebuildCache();
// Verify that accessing the resource returns 401.
$response = $this->httpRequest($this->entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), '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
// route, the non-REST route is used, but can't render into
// application/hal+json, so it returns a 406.
$this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.');
$this->curlClose();
}
}

View file

@ -0,0 +1,90 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\RestLinkManagerTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Core\Url;
use Drupal\simpletest\KernelTestBase;
/**
* Tests that REST type and relation link managers work as expected
* @group rest
*/
class RestLinkManagerTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['rest', 'rest_test', 'system'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('system', ['router']);
\Drupal::service('router.builder')->rebuild();
}
/**
* Tests that type hooks work as expected.
*/
public function testRestLinkManagers() {
\Drupal::moduleHandler()->invoke('rest', 'install');
/* @var \Drupal\rest\LinkManager\TypeLinkManagerInterface $type_manager */
$type_manager = \Drupal::service('rest.link_manager.type');
$base = Url::fromRoute('<front>', [], ['absolute' => TRUE])->toString();
$link = $type_manager->getTypeUri('node', 'page');
$this->assertEqual($link, $base . 'rest/type/node/page');
// Now with optional context.
$link = $type_manager->getTypeUri('node', 'page', ['rest_test' => TRUE]);
$this->assertEqual($link, 'rest_test_type');
/* @var \Drupal\rest\LinkManager\RelationLinkManagerInterface $relation_manager */
$relation_manager = \Drupal::service('rest.link_manager.relation');
$link = $relation_manager->getRelationUri('node', 'page', 'field_ref');
$this->assertEqual($link, $base . 'rest/relation/node/page/field_ref');
// Now with optional context.
$link = $relation_manager->getRelationUri('node', 'page', 'foobar', ['rest_test' => TRUE]);
$this->assertEqual($link, 'rest_test_relation');
}
/**
* Tests that type hooks work as expected even without install hook.
*/
public function testRestLinkManagersNoInstallHook() {
/* @var \Drupal\rest\LinkManager\TypeLinkManagerInterface $type_manager */
$type_manager = \Drupal::service('rest.link_manager.type');
$base = Url::fromRoute('<front>', [], ['absolute' => TRUE])->toString();
$link = $type_manager->getTypeUri('node', 'page');
$this->assertEqual($link, $base . 'rest/type/node/page');
// Now with optional context.
$link = $type_manager->getTypeUri('node', 'page', ['rest_test' => TRUE]);
$this->assertEqual($link, 'rest_test_type');
/* @var \Drupal\rest\LinkManager\RelationLinkManagerInterface $relation_manager */
$relation_manager = \Drupal::service('rest.link_manager.relation');
$link = $relation_manager->getRelationUri('node', 'page', 'field_ref');
$this->assertEqual($link, $base . 'rest/relation/node/page/field_ref');
// Now with optional context.
$link = $relation_manager->getRelationUri('node', 'page', 'foobar', ['rest_test' => TRUE]);
$this->assertEqual($link, 'rest_test_relation');
}
/**
* Tests \Drupal\rest\LinkManager\LinkManager::setLinkDomain().
*/
public function testRestLinkManagersSetLinkDomain() {
/* @var \Drupal\rest\LinkManager\LinkManager $link_manager */
$link_manager = \Drupal::service('rest.link_manager');
$link_manager->setLinkDomain('http://example.com/');
$link = $link_manager->getTypeUri('node', 'page');
$this->assertEqual($link, 'http://example.com/rest/type/node/page');
$link = $link_manager->getRelationUri('node', 'page', 'field_ref');
$this->assertEqual($link, 'http://example.com/rest/relation/node/page/field_ref');
}
}

View file

@ -0,0 +1,230 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\UpdateTest.
*/
namespace Drupal\rest\Tests;
use Drupal\Component\Serialization\Json;
use Drupal\rest\Tests\RESTTestBase;
/**
* Tests the update of resources.
*
* @group rest
*/
class UpdateTest extends RESTTestBase {
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('hal', 'rest', 'entity_test');
/**
* Tests several valid and invalid partial update requests on test entities.
*/
public function testPatchUpdate() {
$serializer = $this->container->get('serializer');
// @todo Test all other entity types here as well.
$entity_type = 'entity_test';
$this->enableService('entity:' . $entity_type, 'PATCH');
// Create a user account that has the required permissions to create
// resources via the REST API.
$permissions = $this->entityPermissions($entity_type, 'update');
$permissions[] = 'restful patch entity:' . $entity_type;
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
$context = ['account' => $account];
// Create an entity and save it to the database.
$entity = $this->entityCreate($entity_type);
$entity->save();
// Create a second stub entity for overwriting a field.
$patch_values['field_test_text'] = array(0 => array(
'value' => $this->randomString(),
'format' => 'plain_text',
));
$patch_entity = entity_create($entity_type, $patch_values);
// We don't want to overwrite the UUID.
unset($patch_entity->uuid);
$serialized = $serializer->serialize($patch_entity, $this->defaultFormat, $context);
// Update the entity over the REST API.
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(204);
// Re-load updated entity from the database.
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertEqual($entity->field_test_text->value, $patch_entity->field_test_text->value, 'Field was successfully updated.');
// Make sure that the field does not get deleted if it is not present in the
// PATCH request.
$normalized = $serializer->normalize($patch_entity, $this->defaultFormat, $context);
unset($normalized['field_test_text']);
$serialized = $serializer->encode($normalized, $this->defaultFormat);
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(204);
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertNotNull($entity->field_test_text->value. 'Test field has not been deleted.');
// Try to empty a field.
$normalized['field_test_text'] = array();
$serialized = $serializer->encode($normalized, $this->defaultFormat);
// Update the entity over the REST API.
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(204);
// Re-load updated entity from the database.
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertNull($entity->field_test_text->value, 'Test field has been cleared.');
// Enable access protection for the text field.
// @see entity_test_entity_field_access()
$entity->field_test_text->value = 'no edit access value';
$entity->field_test_text->format = 'plain_text';
$entity->save();
// Try to empty a field that is access protected.
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(403);
// Re-load the entity from the database.
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertEqual($entity->field_test_text->value, 'no edit access value', 'Text field was not deleted.');
// Try to update an access protected field.
$normalized = $serializer->normalize($patch_entity, $this->defaultFormat, $context);
$normalized['field_test_text'][0]['value'] = 'no access value';
$serialized = $serializer->serialize($normalized, $this->defaultFormat, $context);
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(403);
// Re-load the entity from the database.
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertEqual($entity->field_test_text->value, 'no edit access value', 'Text field was not updated.');
// Try to update the field with a text format this user has no access to.
// First change the original field value so we're allowed to edit it again.
$entity->field_test_text->value = 'test';
$entity->save();
$patch_entity->set('field_test_text', array(
'value' => 'test',
'format' => 'full_html',
));
$serialized = $serializer->serialize($patch_entity, $this->defaultFormat, $context);
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(422);
// Re-load the entity from the database.
$entity = entity_load($entity_type, $entity->id(), TRUE);
$this->assertEqual($entity->field_test_text->format, 'plain_text', 'Text format was not updated.');
// Restore the valid test value.
$entity->field_test_text->value = $this->randomString();
$entity->save();
// Try to send no data at all, which does not make sense on PATCH requests.
$this->httpRequest($entity->urlInfo(), 'PATCH', NULL, $this->defaultMimeType);
$this->assertResponse(400);
// Try to update a non-existing entity with ID 9999.
$this->httpRequest($entity_type . '/9999', 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(404);
$loaded_entity = entity_load($entity_type, 9999, TRUE);
$this->assertFalse($loaded_entity, 'Entity 9999 was not created.');
// Try to send invalid data to trigger the entity validation constraints.
// Send a UUID that is too long.
$entity->set('uuid', $this->randomMachineName(129));
$invalid_serialized = $serializer->serialize($entity, $this->defaultFormat, $context);
$response = $this->httpRequest($entity->urlInfo(), 'PATCH', $invalid_serialized, $this->defaultMimeType);
$this->assertResponse(422);
$error = Json::decode($response);
$this->assertEqual($error['error'], "Unprocessable Entity: validation failed.\nuuid.0.value: <em class=\"placeholder\">UUID</em>: may not be longer than 128 characters.\n");
// Try to update an entity without proper permissions.
$this->drupalLogout();
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(403);
// Try to update a resource which is not REST API enabled.
$this->enableService(FALSE);
$this->drupalLogin($account);
$this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(405);
}
/**
* Tests several valid and invalid update requests for the 'user' entity type.
*/
public function testUpdateUser() {
$serializer = $this->container->get('serializer');
$entity_type = 'user';
// Enables the REST service for 'user' entity type.
$this->enableService('entity:' . $entity_type, 'PATCH');
$permissions = $this->entityPermissions($entity_type, 'update');
$permissions[] = 'restful patch entity:' . $entity_type;
$account = $this->drupalCreateUser($permissions);
$account->set('mail', 'old-email@example.com');
$this->drupalLogin($account);
// Create an entity and save it to the database.
$account->save();
$account->set('changed', NULL);
// Try and set a new email without providing the password.
$account->set('mail', 'new-email@example.com');
$context = ['account' => $account];
$normalized = $serializer->normalize($account, $this->defaultFormat, $context);
$serialized = $serializer->serialize($normalized, $this->defaultFormat, $context);
$response = $this->httpRequest($account->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(422);
$error = Json::decode($response);
$this->assertEqual($error['error'], "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n");
// Try and send the new email with a password.
$normalized['pass'][0]['existing'] = 'wrong';
$serialized = $serializer->serialize($normalized, $this->defaultFormat, $context);
$response = $this->httpRequest($account->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(422);
$error = Json::decode($response);
$this->assertEqual($error['error'], "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n");
// Try again with the password.
$normalized['pass'][0]['existing'] = $account->pass_raw;
$serialized = $serializer->serialize($normalized, $this->defaultFormat, $context);
$this->httpRequest($account->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(204);
// Try to change the password without providing the current password.
$new_password = $this->randomString();
$normalized = $serializer->normalize($account, $this->defaultFormat, $context);
$normalized['pass'][0]['value'] = $new_password;
$serialized = $serializer->serialize($normalized, $this->defaultFormat, $context);
$response = $this->httpRequest($account->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(422);
$error = Json::decode($response);
$this->assertEqual($error['error'], "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Password</em>.\n");
// Try again with the password.
$normalized['pass'][0]['existing'] = $account->pass_raw;
$serialized = $serializer->serialize($normalized, $this->defaultFormat, $context);
$this->httpRequest($account->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType);
$this->assertResponse(204);
// Verify that we can log in with the new password.
$account->pass_raw = $new_password;
$this->drupalLogin($account);
}
}

View file

@ -0,0 +1,547 @@
<?php
/**
* @file
* Contains \Drupal\rest\Tests\Views\StyleSerializerTest.
*/
namespace Drupal\rest\Tests\Views;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Cache\Cache;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\views\Entity\View;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Views;
use Drupal\views\Tests\Plugin\PluginTestBase;
use Drupal\views\Tests\ViewTestData;
use Symfony\Component\HttpFoundation\Request;
/**
* Tests the serializer style plugin.
*
* @group rest
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\style\Serializer
* @see \Drupal\rest\Plugin\views\row\DataEntityRow
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class StyleSerializerTest extends PluginTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* {@inheritdoc}
*/
protected $dumpHeaders = TRUE;
/**
* Modules to install.
*
* @var array
*/
public static $modules = array('views_ui', 'entity_test', 'hal', 'rest_test_views', 'node', 'text', 'field');
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = array('test_serializer_display_field', 'test_serializer_display_entity', 'test_serializer_node_display_field');
/**
* A user with administrative privileges to look at test entity and configure views.
*/
protected $adminUser;
protected function setUp() {
parent::setUp();
ViewTestData::createTestViews(get_class($this), array('rest_test_views'));
$this->adminUser = $this->drupalCreateUser(array('administer views', 'administer entity_test content', 'access user profiles', 'view test entity'));
// Save some entity_test entities.
for ($i = 1; $i <= 10; $i++) {
entity_create('entity_test', array('name' => 'test_' . $i, 'user_id' => $this->adminUser->id()))->save();
}
$this->enableViewsTestModule();
}
/**
* Checks the behavior of the Serializer callback paths and row plugins.
*/
public function testSerializerResponses() {
// Test the serialize callback.
$view = Views::getView('test_serializer_display_field');
$view->initDisplay();
$this->executeView($view);
$actual_json = $this->drupalGetWithFormat('test/serialize/field', 'json');
$this->assertResponse(200);
$this->assertCacheTags($view->getCacheTags());
$this->assertCacheContexts(['languages:language_interface', 'theme', 'request_format']);
// @todo Due to https://www.drupal.org/node/2352009 we can't yet test the
// propagation of cache max-age.
// Test the http Content-type.
$headers = $this->drupalGetHeaders();
$this->assertEqual($headers['content-type'], 'application/json', 'The header Content-type is correct.');
$expected = array();
foreach ($view->result as $row) {
$expected_row = array();
foreach ($view->field as $id => $field) {
$expected_row[$id] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertIdentical($actual_json, json_encode($expected), 'The expected JSON output was found.');
// Test that the rendered output and the preview output are the same.
$view->destroy();
$view->setDisplay('rest_export_1');
// Mock the request content type by setting it on the display handler.
$view->display_handler->setContentType('json');
$output = $view->preview();
$this->assertIdentical($actual_json, drupal_render_root($output), 'The expected JSON preview output was found.');
// Test a 403 callback.
$this->drupalGet('test/serialize/denied');
$this->assertResponse(403);
// Test the entity rows.
$view = Views::getView('test_serializer_display_entity');
$view->initDisplay();
$this->executeView($view);
// Get the serializer service.
$serializer = $this->container->get('serializer');
$entities = array();
foreach ($view->result as $row) {
$entities[] = $row->_entity;
}
$expected = $serializer->serialize($entities, 'json');
$actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'json');
$this->assertResponse(200);
$this->assertIdentical($actual_json, $expected, 'The expected JSON output was found.');
$expected_cache_tags = $view->getCacheTags();
$expected_cache_tags[] = 'entity_test_list';
/** @var \Drupal\Core\Entity\EntityInterface $entity */
foreach ($entities as $entity) {
$expected_cache_tags = Cache::mergeTags($expected_cache_tags, $entity->getCacheTags());
}
$this->assertCacheTags($expected_cache_tags);
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
$expected = $serializer->serialize($entities, 'hal_json');
$actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'hal_json');
$this->assertIdentical($actual_json, $expected, 'The expected HAL output was found.');
$this->assertCacheTags($expected_cache_tags);
// Change the default format to xml.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', array(
'type' => 'serializer',
'options' => array(
'uses_fields' => FALSE,
'formats' => array(
'xml' => 'xml',
),
),
));
$view->save();
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGet('test/serialize/entity');
$this->assertIdentical($actual_xml, $expected, 'The expected XML output was found.');
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
// Allow multiple formats.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', array(
'type' => 'serializer',
'options' => array(
'uses_fields' => FALSE,
'formats' => array(
'xml' => 'xml',
'json' => 'json',
),
),
));
$view->save();
$expected = $serializer->serialize($entities, 'json');
$actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'json');
$this->assertIdentical($actual_json, $expected, 'The expected JSON output was found.');
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGetWithFormat('test/serialize/entity', 'xml');
$this->assertIdentical($actual_xml, $expected, 'The expected XML output was found.');
}
/**
* Sets up a request on the request stack with a specified format.
*
* @param string $format
* The new request format.
*/
protected function addRequestWithFormat($format) {
$request = \Drupal::request();
$request = clone $request;
$request->setRequestFormat($format);
\Drupal::requestStack()->push($request);
}
/**
* Tests REST export with views render caching enabled.
*/
public function testRestRenderCaching() {
$this->drupalLogin($this->adminUser);
/** @var \Drupal\Core\Render\RenderCacheInterface $render_cache */
$render_cache = \Drupal::service('render_cache');
// Enable render caching for the views.
/** @var \Drupal\views\ViewEntityInterface $storage */
$storage = View::load('test_serializer_display_entity');
$options = &$storage->getDisplay('default');
$options['display_options']['cache'] = [
'type' => 'tag',
];
$storage->save();
$original = DisplayPluginBase::buildBasicRenderable('test_serializer_display_entity', 'rest_export_1');
// Ensure that there is no corresponding render cache item yet.
$original['#cache'] += ['contexts' => []];
$original['#cache']['contexts'] = Cache::mergeContexts($original['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']);
$cache_tags = [
'config:views.view.test_serializer_display_entity',
'entity_test:1',
'entity_test:10',
'entity_test:2',
'entity_test:3',
'entity_test:4',
'entity_test:5',
'entity_test:6',
'entity_test:7',
'entity_test:8',
'entity_test:9',
'entity_test_list'
];
$cache_contexts = [
'entity_test_view_grants',
'languages:language_interface',
'theme',
'request_format',
];
$this->assertFalse($render_cache->get($original));
// Request the page, once in XML and once in JSON to ensure that the caching
// varies by it.
$result1 = $this->drupalGetJSON('test/serialize/entity');
$this->addRequestWithFormat('json');
$this->assertHeader('content-type', 'application/json');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
$result_xml = $this->drupalGetWithFormat('test/serialize/entity', 'xml');
$this->addRequestWithFormat('xml');
$this->assertHeader('content-type', 'text/xml; charset=UTF-8');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
// Ensure that the XML output is different from the JSON one.
$this->assertNotEqual($result1, $result_xml);
// Ensure that the cached page works.
$result2 = $this->drupalGetJSON('test/serialize/entity');
$this->addRequestWithFormat('json');
$this->assertHeader('content-type', 'application/json');
$this->assertEqual($result2, $result1);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
// Create a new entity and ensure that the cache tags are taken over.
EntityTest::create(['name' => 'test_11', 'user_id' => $this->adminUser->id()])->save();
$result3 = $this->drupalGetJSON('test/serialize/entity');
$this->addRequestWithFormat('json');
$this->assertHeader('content-type', 'application/json');
$this->assertNotEqual($result3, $result2);
// Add the new entity cache tag and remove the first one, because we just
// show 10 items in total.
$cache_tags[] = 'entity_test:11';
unset($cache_tags[array_search('entity_test:1', $cache_tags)]);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
}
/**
* Tests the response format configuration.
*/
public function testResponseFormatConfiguration() {
$this->drupalLogin($this->adminUser);
$style_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/style_options';
// Select only 'xml' as an accepted format.
$this->drupalPostForm($style_options, array('style_options[formats][xml]' => 'xml'), t('Apply'));
$this->drupalPostForm(NULL, array(), t('Save'));
// Should return a 406.
$this->drupalGetWithFormat('test/serialize/field', 'json');
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(406, 'A 406 response was returned when JSON was requested.');
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'xml');
$this->assertHeader('content-type', 'text/xml; charset=UTF-8');
$this->assertResponse(200, 'A 200 response was returned when XML was requested.');
// Add 'json' as an accepted format, so we have multiple.
$this->drupalPostForm($style_options, array('style_options[formats][json]' => 'json'), t('Apply'));
$this->drupalPostForm(NULL, array(), t('Save'));
// Should return a 200.
// @todo This should be fixed when we have better content negotiation.
$this->drupalGet('test/serialize/field');
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when any format was requested.');
// Should return a 200. Emulates a sample Firefox header.
$this->drupalGet('test/serialize/field', array(), array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'));
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when a browser accept header was requested.');
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'json');
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when JSON was requested.');
$headers = $this->drupalGetHeaders();
$this->assertEqual($headers['content-type'], 'application/json', 'The header Content-type is correct.');
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'xml');
$this->assertHeader('content-type', 'text/xml; charset=UTF-8');
$this->assertResponse(200, 'A 200 response was returned when XML was requested');
$headers = $this->drupalGetHeaders();
$this->assertTrue(strpos($headers['content-type'], 'text/xml') !== FALSE, 'The header Content-type is correct.');
// Should return a 406.
$this->drupalGetWithFormat('test/serialize/field', 'html');
// We want to show the first format by default, see
// \Drupal\rest\Plugin\views\style\Serializer::render.
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when HTML was requested.');
// Now configure now format, so all of them should be allowed.
$this->drupalPostForm($style_options, array('style_options[formats][json]' => '0', 'style_options[formats][xml]' => '0'), t('Apply'));
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'json');
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when JSON was requested.');
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'xml');
$this->assertHeader('content-type', 'text/xml; charset=UTF-8');
$this->assertResponse(200, 'A 200 response was returned when XML was requested');
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'html');
// We want to show the first format by default, see
// \Drupal\rest\Plugin\views\style\Serializer::render.
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when HTML was requested.');
}
/**
* Test the field ID alias functionality of the DataFieldRow plugin.
*/
public function testUIFieldAlias() {
$this->drupalLogin($this->adminUser);
// Test the UI settings for adding field ID aliases.
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$row_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options';
$this->assertLinkByHref($row_options);
// Test an empty string for an alias, this should not be used. This also
// tests that the form can be submitted with no aliases.
$this->drupalPostForm($row_options, array('row_options[field_options][name][alias]' => ''), t('Apply'));
$this->drupalPostForm(NULL, array(), t('Save'));
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$expected = array();
foreach ($view->result as $row) {
$expected_row = array();
foreach ($view->field as $id => $field) {
$expected_row[$id] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertIdentical($this->drupalGetJSON('test/serialize/field'), $expected);
// Test a random aliases for fields, they should be replaced.
$alias_map = array(
'name' => $this->randomMachineName(),
// Use # to produce an invalid character for the validation.
'nothing' => '#' . $this->randomMachineName(),
'created' => 'created',
);
$edit = array('row_options[field_options][name][alias]' => $alias_map['name'], 'row_options[field_options][nothing][alias]' => $alias_map['nothing']);
$this->drupalPostForm($row_options, $edit, t('Apply'));
$this->assertText(t('The machine-readable name must contain only letters, numbers, dashes and underscores.'));
// Change the map alias value to a valid one.
$alias_map['nothing'] = $this->randomMachineName();
$edit = array('row_options[field_options][name][alias]' => $alias_map['name'], 'row_options[field_options][nothing][alias]' => $alias_map['nothing']);
$this->drupalPostForm($row_options, $edit, t('Apply'));
$this->drupalPostForm(NULL, array(), t('Save'));
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$expected = array();
foreach ($view->result as $row) {
$expected_row = array();
foreach ($view->field as $id => $field) {
$expected_row[$alias_map[$id]] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertIdentical($this->drupalGetJSON('test/serialize/field'), $expected);
}
/**
* Tests the raw output options for row field rendering.
*/
public function testFieldRawOutput() {
$this->drupalLogin($this->adminUser);
// Test the UI settings for adding field ID aliases.
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$row_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options';
$this->assertLinkByHref($row_options);
// Test an empty string for an alias, this should not be used. This also
// tests that the form can be submitted with no aliases.
$this->drupalPostForm($row_options, array('row_options[field_options][created][raw_output]' => '1'), t('Apply'));
$this->drupalPostForm(NULL, array(), t('Save'));
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
// Just test the raw 'created' value against each row.
foreach ($this->drupalGetJSON('test/serialize/field') as $index => $values) {
$this->assertIdentical($values['created'], $view->result[$index]->views_test_data_created, 'Expected raw created value found.');
}
}
/**
* Tests the live preview output for json output.
*/
public function testLivePreview() {
// We set up a request so it looks like an request in the live preview.
$request = new Request();
$request->setFormat('drupal_ajax', 'application/vnd.drupal-ajax');
$request->headers->set('Accept', 'application/vnd.drupal-ajax');
/** @var \Symfony\Component\HttpFoundation\RequestStack $request_stack */
$request_stack = \Drupal::service('request_stack');
$request_stack->push($request);
$view = Views::getView('test_serializer_display_entity');
$view->setDisplay('rest_export_1');
$this->executeView($view);
// Get the serializer service.
$serializer = $this->container->get('serializer');
$entities = array();
foreach ($view->result as $row) {
$entities[] = $row->_entity;
}
$expected = SafeMarkup::checkPlain($serializer->serialize($entities, 'json'));
$view->live_preview = TRUE;
$build = $view->preview();
$rendered_json = $build['#markup'];
$this->assertEqual($rendered_json, $expected, 'Ensure the previewed json is escaped.');
$view->destroy();
$expected = SafeMarkup::checkPlain($serializer->serialize($entities, 'xml'));
// Change the request format to xml.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', array(
'type' => 'serializer',
'options' => array(
'uses_fields' => FALSE,
'formats' => array(
'xml' => 'xml',
),
),
));
$this->executeView($view);
$build = $view->preview();
$rendered_xml = $build['#markup'];
$this->assertEqual($rendered_xml, $expected, 'Ensure we preview xml when we change the request format.');
}
/**
* Tests the views interface for REST export displays.
*/
public function testSerializerViewsUI() {
$this->drupalLogin($this->adminUser);
// Click the "Update preview button".
$this->drupalPostForm('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1', $edit = array(), t('Update preview'));
$this->assertResponse(200);
// Check if we receive the expected result.
$result = $this->xpath('//div[@id="views-live-preview"]/pre');
$this->assertIdentical($this->drupalGet('test/serialize/field'), (string) $result[0], 'The expected JSON preview output was found.');
}
/**
* Tests the field row style using fieldapi fields.
*/
public function testFieldapiField() {
$this->drupalCreateContentType(array('type' => 'page'));
$node = $this->drupalCreateNode();
$result = $this->drupalGetJSON('test/serialize/node-field');
$this->assertEqual($result[0]['nid'], $node->id());
$this->assertEqual($result[0]['body'], $node->body->processed);
// Make sure that serialized fields are not exposed to XSS.
$node = $this->drupalCreateNode();
$node->body = [
'value' => '<script type="text/javascript">alert("node-body");</script>' . $this->randomMachineName(32),
'format' => filter_default_format(),
];
$node->save();
$result = $this->drupalGetJSON('test/serialize/node-field');
$this->assertEqual($result[1]['nid'], $node->id());
$this->assertTrue(strpos($this->getRawContent(), "<script") === FALSE, "No script tag is present in the raw page contents.");
}
}