first commit

This commit is contained in:
2020-06-08 23:57:36 +02:00
commit 6277454f7a
16057 changed files with 1715382 additions and 0 deletions
@@ -0,0 +1,62 @@
<?php
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\dblog\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 REST resource plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the REST resource plugin.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $label;
/**
* The serialization class to deserialize serialized data into.
*
* @see \Symfony\Component\Serializer\SerializerInterface's "type" parameter.
*
* @var string (optional)
*/
public $serialization_class;
/**
* The URI paths that this REST resource plugin provides.
*
* Key-value pairs, with link relation type plugin IDs as keys, and URL
* templates as values.
*
* @see core/core.link_relation_types.yml
*
* @var string[]
*/
public $uri_paths = [];
}
@@ -0,0 +1,275 @@
<?php
namespace Drupal\rest\Entity;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\rest\RestResourceConfigInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Calculates rest resource config dependencies.
*
* @internal
*/
class ConfigDependencies implements ContainerInjectionInterface {
/**
* The serialization format providers, keyed by format.
*
* @var string[]
*/
protected $formatProviders;
/**
* The authentication providers, keyed by ID.
*
* @var string[]
*/
protected $authProviders;
/**
* Creates a new ConfigDependencies instance.
*
* @param string[] $format_providers
* The serialization format providers, keyed by format.
* @param string[] $auth_providers
* The authentication providers, keyed by ID.
*/
public function __construct(array $format_providers, array $auth_providers) {
$this->formatProviders = $format_providers;
$this->authProviders = $auth_providers;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->getParameter('serializer.format_providers'),
$container->getParameter('authentication_providers')
);
}
/**
* Calculates dependencies of a specific rest resource configuration.
*
* This function returns dependencies in a non-sorted, non-unique manner. It
* is therefore the caller's responsibility to sort and remove duplicates
* from the result prior to saving it with the configuration or otherwise
* using it in a way that requires that. For example,
* \Drupal\rest\Entity\RestResourceConfig::calculateDependencies() does this
* via its \Drupal\Core\Entity\DependencyTrait::addDependency() method.
*
* @param \Drupal\rest\RestResourceConfigInterface $rest_config
* The rest configuration.
*
* @return string[][]
* Dependencies keyed by dependency type.
*
* @see \Drupal\rest\Entity\RestResourceConfig::calculateDependencies()
*/
public function calculateDependencies(RestResourceConfigInterface $rest_config) {
$granularity = $rest_config->get('granularity');
// Dependency calculation is the same for either granularity, the most
// notable difference is that for the 'resource' granularity, the same
// authentication providers and formats are supported for every method.
switch ($granularity) {
case RestResourceConfigInterface::METHOD_GRANULARITY:
$methods = $rest_config->getMethods();
break;
case RestResourceConfigInterface::RESOURCE_GRANULARITY:
$methods = array_slice($rest_config->getMethods(), 0, 1);
break;
default:
throw new \InvalidArgumentException('Invalid granularity specified.');
}
// The dependency lists for authentication providers and formats
// generated on container build.
$dependencies = [];
foreach ($methods as $request_method) {
// Add dependencies based on the supported authentication providers.
foreach ($rest_config->getAuthenticationProviders($request_method) as $auth) {
if (isset($this->authProviders[$auth])) {
$module_name = $this->authProviders[$auth];
$dependencies['module'][] = $module_name;
}
}
// Add dependencies based on the supported authentication formats.
foreach ($rest_config->getFormats($request_method) as $format) {
if (isset($this->formatProviders[$format])) {
$module_name = $this->formatProviders[$format];
$dependencies['module'][] = $module_name;
}
}
}
return $dependencies;
}
/**
* Informs the entity that entities it depends on will be deleted.
*
* @param \Drupal\rest\RestResourceConfigInterface $rest_config
* The rest configuration.
* @param array $dependencies
* An array of dependencies that will be deleted keyed by dependency type.
* Dependency types are, for example, entity, module and theme.
*
* @return bool
* TRUE if the entity has been changed as a result, FALSE if not.
*
* @see \Drupal\Core\Config\Entity\ConfigEntityInterface::onDependencyRemoval()
*/
public function onDependencyRemoval(RestResourceConfigInterface $rest_config, array $dependencies) {
$granularity = $rest_config->get('granularity');
switch ($granularity) {
case RestResourceConfigInterface::METHOD_GRANULARITY:
return $this->onDependencyRemovalForMethodGranularity($rest_config, $dependencies);
case RestResourceConfigInterface::RESOURCE_GRANULARITY:
return $this->onDependencyRemovalForResourceGranularity($rest_config, $dependencies);
default:
throw new \InvalidArgumentException('Invalid granularity specified.');
}
}
/**
* Informs the entity that entities it depends on will be deleted.
*
* @param \Drupal\rest\RestResourceConfigInterface $rest_config
* The rest configuration.
* @param array $dependencies
* An array of dependencies that will be deleted keyed by dependency type.
* Dependency types are, for example, entity, module and theme.
*
* @return bool
* TRUE if the entity has been changed as a result, FALSE if not.
*/
protected function onDependencyRemovalForMethodGranularity(RestResourceConfigInterface $rest_config, array $dependencies) {
$changed = FALSE;
// Only module-related dependencies can be fixed. All other types of
// dependencies cannot, because they were not generated based on supported
// authentication providers or formats.
if (isset($dependencies['module'])) {
// Try to fix dependencies.
$removed_auth = array_keys(array_intersect($this->authProviders, $dependencies['module']));
$removed_formats = array_keys(array_intersect($this->formatProviders, $dependencies['module']));
$configuration_before = $configuration = $rest_config->get('configuration');
if (!empty($removed_auth) || !empty($removed_formats)) {
// Try to fix dependency problems by removing affected
// authentication providers and formats.
foreach (array_keys($rest_config->get('configuration')) as $request_method) {
foreach ($removed_formats as $format) {
if (in_array($format, $rest_config->getFormats($request_method), TRUE)) {
$configuration[$request_method]['supported_formats'] = array_diff($configuration[$request_method]['supported_formats'], $removed_formats);
}
}
foreach ($removed_auth as $auth) {
if (in_array($auth, $rest_config->getAuthenticationProviders($request_method), TRUE)) {
$configuration[$request_method]['supported_auth'] = array_diff($configuration[$request_method]['supported_auth'], $removed_auth);
}
}
if (empty($configuration[$request_method]['supported_auth'])) {
// Remove the key if there are no more authentication providers
// supported by this request method.
unset($configuration[$request_method]['supported_auth']);
}
if (empty($configuration[$request_method]['supported_formats'])) {
// Remove the key if there are no more formats supported by this
// request method.
unset($configuration[$request_method]['supported_formats']);
}
if (empty($configuration[$request_method])) {
// Remove the request method altogether if it no longer has any
// supported authentication providers or formats.
unset($configuration[$request_method]);
}
}
}
if ($configuration_before != $configuration && !empty($configuration)) {
$rest_config->set('configuration', $configuration);
// Only mark the dependencies problems as fixed if there is any
// configuration left.
$changed = TRUE;
}
}
// If the dependency problems are not marked as fixed at this point they
// should be related to the resource plugin and the config entity should
// be deleted.
return $changed;
}
/**
* Informs the entity that entities it depends on will be deleted.
*
* @param \Drupal\rest\RestResourceConfigInterface $rest_config
* The rest configuration.
* @param array $dependencies
* An array of dependencies that will be deleted keyed by dependency type.
* Dependency types are, for example, entity, module and theme.
*
* @return bool
* TRUE if the entity has been changed as a result, FALSE if not.
*/
protected function onDependencyRemovalForResourceGranularity(RestResourceConfigInterface $rest_config, array $dependencies) {
$changed = FALSE;
// Only module-related dependencies can be fixed. All other types of
// dependencies cannot, because they were not generated based on supported
// authentication providers or formats.
if (isset($dependencies['module'])) {
// Try to fix dependencies.
$removed_auth = array_keys(array_intersect($this->authProviders, $dependencies['module']));
$removed_formats = array_keys(array_intersect($this->formatProviders, $dependencies['module']));
$configuration_before = $configuration = $rest_config->get('configuration');
if (!empty($removed_auth) || !empty($removed_formats)) {
// All methods support the same formats and authentication providers, so
// get those for whichever the first listed method is.
$first_method = $rest_config->getMethods()[0];
// Try to fix dependency problems by removing affected
// authentication providers and formats.
foreach ($removed_formats as $format) {
if (in_array($format, $rest_config->getFormats($first_method), TRUE)) {
$configuration['formats'] = array_diff($configuration['formats'], $removed_formats);
}
}
foreach ($removed_auth as $auth) {
if (in_array($auth, $rest_config->getAuthenticationProviders($first_method), TRUE)) {
$configuration['authentication'] = array_diff($configuration['authentication'], $removed_auth);
}
}
if (empty($configuration['authentication'])) {
// Remove the key if there are no more authentication providers
// supported.
unset($configuration['authentication']);
}
if (empty($configuration['formats'])) {
// Remove the key if there are no more formats supported.
unset($configuration['formats']);
}
if (empty($configuration['authentication']) || empty($configuration['formats'])) {
// If there no longer are any supported authentication providers or
// formats, this REST resource can no longer function, and so we
// cannot fix this config entity to keep it working.
$configuration = [];
}
}
if ($configuration_before != $configuration && !empty($configuration)) {
$rest_config->set('configuration', $configuration);
// Only mark the dependencies problems as fixed if there is any
// configuration left.
$changed = TRUE;
}
}
// If the dependency problems are not marked as fixed at this point they
// should be related to the resource plugin and the config entity should
// be deleted.
return $changed;
}
}
@@ -0,0 +1,290 @@
<?php
namespace Drupal\rest\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection;
use Drupal\rest\RestResourceConfigInterface;
/**
* Defines a RestResourceConfig configuration entity class.
*
* @ConfigEntityType(
* id = "rest_resource_config",
* label = @Translation("REST resource configuration"),
* label_collection = @Translation("REST resource configurations"),
* label_singular = @Translation("REST resource configuration"),
* label_plural = @Translation("REST resource configurations"),
* label_count = @PluralTranslation(
* singular = "@count REST resource configuration",
* plural = "@count REST resource configurations",
* ),
* config_prefix = "resource",
* admin_permission = "administer rest resources",
* label_callback = "getLabelFromPlugin",
* entity_keys = {
* "id" = "id"
* },
* config_export = {
* "id",
* "plugin_id",
* "granularity",
* "configuration"
* }
* )
*/
class RestResourceConfig extends ConfigEntityBase implements RestResourceConfigInterface {
/**
* The REST resource config id.
*
* @var string
*/
protected $id;
/**
* The REST resource plugin id.
*
* @var string
*/
protected $plugin_id;
/**
* The REST resource configuration granularity.
*
* Currently either:
* - \Drupal\rest\RestResourceConfigInterface::METHOD_GRANULARITY
* - \Drupal\rest\RestResourceConfigInterface::RESOURCE_GRANULARITY
*
* @var string
*/
protected $granularity;
/**
* The REST resource configuration.
*
* @var array
*/
protected $configuration;
/**
* The rest resource plugin manager.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $pluginManager;
/**
* {@inheritdoc}
*/
public function __construct(array $values, $entity_type) {
parent::__construct($values, $entity_type);
// The config entity id looks like the plugin id but uses __ instead of :
// because : is not valid for config entities.
if (!isset($this->plugin_id) && isset($this->id)) {
// Generate plugin_id on first entity creation.
$this->plugin_id = str_replace('.', ':', $this->id);
}
}
/**
* The label callback for this configuration entity.
*
* @return string The label.
*/
protected function getLabelFromPlugin() {
$plugin_definition = $this->getResourcePluginManager()
->getDefinition(['id' => $this->plugin_id]);
return $plugin_definition['label'];
}
/**
* Returns the resource plugin manager.
*
* @return \Drupal\Component\Plugin\PluginManagerInterface
*/
protected function getResourcePluginManager() {
if (!isset($this->pluginManager)) {
$this->pluginManager = \Drupal::service('plugin.manager.rest');
}
return $this->pluginManager;
}
/**
* {@inheritdoc}
*/
public function getResourcePlugin() {
return $this->getPluginCollections()['resource']->get($this->plugin_id);
}
/**
* {@inheritdoc}
*/
public function getMethods() {
switch ($this->granularity) {
case RestResourceConfigInterface::METHOD_GRANULARITY:
return $this->getMethodsForMethodGranularity();
case RestResourceConfigInterface::RESOURCE_GRANULARITY:
return $this->configuration['methods'];
default:
throw new \InvalidArgumentException('Invalid granularity specified.');
}
}
/**
* Retrieves a list of supported HTTP methods for this resource.
*
* @return string[]
* A list of supported HTTP methods.
*/
protected function getMethodsForMethodGranularity() {
$methods = array_keys($this->configuration);
return array_map([$this, 'normalizeRestMethod'], $methods);
}
/**
* {@inheritdoc}
*/
public function getAuthenticationProviders($method) {
switch ($this->granularity) {
case RestResourceConfigInterface::METHOD_GRANULARITY:
return $this->getAuthenticationProvidersForMethodGranularity($method);
case RestResourceConfigInterface::RESOURCE_GRANULARITY:
return $this->configuration['authentication'];
default:
throw new \InvalidArgumentException('Invalid granularity specified.');
}
}
/**
* Retrieves a list of supported authentication providers.
*
* @param string $method
* The request method e.g GET or POST.
*
* @return string[]
* A list of supported authentication provider IDs.
*/
public function getAuthenticationProvidersForMethodGranularity($method) {
$method = $this->normalizeRestMethod($method);
if (in_array($method, $this->getMethods()) && isset($this->configuration[$method]['supported_auth'])) {
return $this->configuration[$method]['supported_auth'];
}
return [];
}
/**
* {@inheritdoc}
*/
public function getFormats($method) {
switch ($this->granularity) {
case RestResourceConfigInterface::METHOD_GRANULARITY:
return $this->getFormatsForMethodGranularity($method);
case RestResourceConfigInterface::RESOURCE_GRANULARITY:
return $this->configuration['formats'];
default:
throw new \InvalidArgumentException('Invalid granularity specified.');
}
}
/**
* Retrieves a list of supported response formats.
*
* @param string $method
* The request method e.g GET or POST.
*
* @return string[]
* A list of supported format IDs.
*/
protected function getFormatsForMethodGranularity($method) {
$method = $this->normalizeRestMethod($method);
if (in_array($method, $this->getMethods()) && isset($this->configuration[$method]['supported_formats'])) {
return $this->configuration[$method]['supported_formats'];
}
return [];
}
/**
* {@inheritdoc}
*/
public function getPluginCollections() {
return [
'resource' => new DefaultSingleLazyPluginCollection($this->getResourcePluginManager(), $this->plugin_id, []),
];
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
parent::calculateDependencies();
foreach ($this->getRestResourceDependencies()->calculateDependencies($this) as $type => $dependencies) {
foreach ($dependencies as $dependency) {
$this->addDependency($type, $dependency);
}
}
return $this;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
$parent = parent::onDependencyRemoval($dependencies);
// If the dependency problems are not marked as fixed at this point they
// should be related to the resource plugin and the config entity should
// be deleted.
$changed = $this->getRestResourceDependencies()->onDependencyRemoval($this, $dependencies);
return $parent || $changed;
}
/**
* Returns the REST resource dependencies.
*
* @return \Drupal\rest\Entity\ConfigDependencies
*/
protected function getRestResourceDependencies() {
return \Drupal::service('class_resolver')->getInstanceFromDefinition(ConfigDependencies::class);
}
/**
* Normalizes the method.
*
* @param string $method
* The request method.
*
* @return string
* The normalized request method.
*/
protected function normalizeRestMethod($method) {
return strtoupper($method);
}
/**
* {@inheritdoc}
*/
public function postSave(EntityStorageInterface $storage, $update = TRUE) {
parent::postSave($storage, $update);
\Drupal::service('router.builder')->setRebuildNeeded();
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface $storage, array $entities) {
parent::postDelete($storage, $entities);
\Drupal::service('router.builder')->setRebuildNeeded();
}
}
@@ -0,0 +1,74 @@
<?php
namespace Drupal\rest\EventSubscriber;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteBuildEvent;
use Drupal\Core\Routing\RoutingEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Generates a 'create' route for an entity type if it has a REST POST route.
*/
class EntityResourcePostRouteSubscriber implements EventSubscriberInterface {
/**
* The REST resource config storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $resourceConfigStorage;
/**
* Constructs a new EntityResourcePostRouteSubscriber instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->resourceConfigStorage = $entity_type_manager->getStorage('rest_resource_config');
}
/**
* Provides routes on route rebuild time.
*
* @param \Drupal\Core\Routing\RouteBuildEvent $event
* The route build event.
*/
public function onDynamicRouteEvent(RouteBuildEvent $event) {
$route_collection = $event->getRouteCollection();
$resource_configs = $this->resourceConfigStorage->loadMultiple();
// Iterate over all REST resource config entities.
foreach ($resource_configs as $resource_config) {
// We only care about REST resource config entities for the
// \Drupal\rest\Plugin\rest\resource\EntityResource plugin.
$plugin_id = $resource_config->toArray()['plugin_id'];
if (substr($plugin_id, 0, 6) !== 'entity') {
continue;
}
$entity_type_id = substr($plugin_id, 7);
$rest_post_route_name = "rest.entity.$entity_type_id.POST";
if ($rest_post_route = $route_collection->get($rest_post_route_name)) {
// Create a route for the 'create' link relation type for this entity
// type that uses the same route definition as the REST 'POST' route
// which use that entity type.
// @see \Drupal\Core\Entity\Entity::toUrl()
$entity_create_route_name = "entity.$entity_type_id.create";
$route_collection->add($entity_create_route_name, $rest_post_route);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
// Priority -10, to run after \Drupal\rest\Routing\ResourceRoutes, which has
// priority 0.
$events[RoutingEvents::DYNAMIC][] = ['onDynamicRouteEvent', -10];
return $events;
}
}
@@ -0,0 +1,230 @@
<?php
namespace Drupal\rest\EventSubscriber;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\rest\ResourceResponseInterface;
use Drupal\serialization\Normalizer\CacheableNormalizerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Response subscriber that serializes and removes ResourceResponses' data.
*/
class ResourceResponseSubscriber implements EventSubscriberInterface {
/**
* The serializer.
*
* @var \Symfony\Component\Serializer\SerializerInterface
*/
protected $serializer;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructs a ResourceResponseSubscriber object.
*
* @param \Symfony\Component\Serializer\SerializerInterface $serializer
* The serializer.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
*/
public function __construct(SerializerInterface $serializer, RendererInterface $renderer, RouteMatchInterface $route_match) {
$this->serializer = $serializer;
$this->renderer = $renderer;
$this->routeMatch = $route_match;
}
/**
* Serializes ResourceResponse responses' data, and removes that data.
*
* @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
* The event to process.
*/
public function onResponse(FilterResponseEvent $event) {
$response = $event->getResponse();
if (!$response instanceof ResourceResponseInterface) {
return;
}
$request = $event->getRequest();
$format = $this->getResponseFormat($this->routeMatch, $request);
$this->renderResponseBody($request, $response, $this->serializer, $format);
$event->setResponse($this->flattenResponse($response));
}
/**
* Determines the format to respond in.
*
* Respects the requested format if one is specified. However, it is common to
* forget to specify a response format in case of a POST or PATCH. Rather than
* simply throwing an error, we apply the robustness principle: when POSTing
* or PATCHing using a certain format, you probably expect a response in that
* same format.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return string
* The response format.
*/
public function getResponseFormat(RouteMatchInterface $route_match, Request $request) {
$route = $route_match->getRouteObject();
$acceptable_response_formats = $route->hasRequirement('_format') ? explode('|', $route->getRequirement('_format')) : [];
$acceptable_request_formats = $route->hasRequirement('_content_type_format') ? explode('|', $route->getRequirement('_content_type_format')) : [];
$acceptable_formats = $request->isMethodCacheable() ? $acceptable_response_formats : $acceptable_request_formats;
$requested_format = $request->getRequestFormat();
$content_type_format = $request->getContentType();
// If an acceptable response format is requested, then use that. Otherwise,
// including and particularly when the client forgot to specify a response
// format, then use heuristics to select the format that is most likely
// expected.
if (in_array($requested_format, $acceptable_response_formats, TRUE)) {
return $requested_format;
}
// If a request body is present, then use the format corresponding to the
// request body's Content-Type for the response, if it's an acceptable
// format for the request.
if (!empty($request->getContent()) && in_array($content_type_format, $acceptable_request_formats, TRUE)) {
return $content_type_format;
}
// Otherwise, use the first acceptable format.
if (!empty($acceptable_formats)) {
return $acceptable_formats[0];
}
// Sometimes, there are no acceptable formats, e.g. DELETE routes.
return NULL;
}
/**
* Renders a resource response body.
*
* During serialization, encoders and normalizers are able to explicitly
* bubble cacheability metadata via the 'cacheability' key-value pair in the
* received context. This bubbled cacheability metadata will be applied to the
* the response.
*
* In versions of Drupal prior to 8.5, implicit bubbling of cacheability
* metadata was allowed because there was no explicit cacheability metadata
* bubbling API. To maintain backwards compatibility, we continue to support
* this, but support for this will be dropped in Drupal 9.0.0. This is
* especially useful when interacting with APIs that implicitly invoke
* rendering (for example: generating URLs): this allows those to "leak", and
* we collect their bubbled cacheability metadata automatically in a render
* context.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param \Drupal\rest\ResourceResponseInterface $response
* The response from the REST resource.
* @param \Symfony\Component\Serializer\SerializerInterface $serializer
* The serializer to use.
* @param string|null $format
* The response format, or NULL in case the response does not need a format,
* for example for the response to a DELETE request.
*
* @todo Add test coverage for language negotiation contexts in
* https://www.drupal.org/node/2135829.
*/
protected function renderResponseBody(Request $request, ResourceResponseInterface $response, SerializerInterface $serializer, $format) {
$data = $response->getResponseData();
// If there is data to send, serialize and set it as the response body.
if ($data !== NULL) {
$serialization_context = [
'request' => $request,
CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY => new CacheableMetadata(),
];
// @deprecated In Drupal 8.5.0, will be removed before Drupal 9.0.0. Use
// explicit cacheability metadata bubbling instead. (The wrapping call to
// executeInRenderContext() will be removed before Drupal 9.0.0.)
$context = new RenderContext();
$output = $this->renderer
->executeInRenderContext($context, function () use ($serializer, $data, $format, $serialization_context) {
return $serializer->serialize($data, $format, $serialization_context);
});
if ($response instanceof CacheableResponseInterface) {
if (!$context->isEmpty()) {
@trigger_error('Implicit cacheability metadata bubbling (onto the global render context) in normalizers is deprecated since Drupal 8.5.0 and will be removed in Drupal 9.0.0. Use the "cacheability" serialization context instead, for explicit cacheability metadata bubbling. See https://www.drupal.org/node/2918937', E_USER_DEPRECATED);
$response->addCacheableDependency($context->pop());
}
$response->addCacheableDependency($serialization_context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
}
$response->setContent($output);
$response->headers->set('Content-Type', $request->getMimeType($format));
}
}
/**
* Flattens a fully rendered resource response.
*
* Ensures that complex data structures in ResourceResponse::getResponseData()
* are not serialized. Not doing this means that caching this response object
* requires unserializing the PHP data when reading this response object from
* cache, which can be very costly, and is unnecessary.
*
* @param \Drupal\rest\ResourceResponseInterface $response
* A fully rendered resource response.
*
* @return \Drupal\Core\Cache\CacheableResponse|\Symfony\Component\HttpFoundation\Response
* The flattened response.
*/
protected function flattenResponse(ResourceResponseInterface $response) {
$final_response = ($response instanceof CacheableResponseInterface) ? new CacheableResponse() : new Response();
$final_response->setContent($response->getContent());
$final_response->setStatusCode($response->getStatusCode());
$final_response->setProtocolVersion($response->getProtocolVersion());
if ($response->getCharset()) {
$final_response->setCharset($response->getCharset());
}
$final_response->headers = clone $response->headers;
if ($final_response instanceof CacheableResponseInterface) {
$final_response->addCacheableDependency($response->getCacheableMetadata());
}
return $final_response;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
// Run before \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber
// (priority 100), so that Dynamic Page Cache can cache flattened responses.
$events[KernelEvents::RESPONSE][] = ['onResponse', 128];
return $events;
}
}
@@ -0,0 +1,54 @@
<?php
namespace Drupal\rest\EventSubscriber;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Routing\RouteBuilderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* A subscriber triggering a route rebuild when certain configuration changes.
*/
class RestConfigSubscriber implements EventSubscriberInterface {
/**
* The router builder.
*
* @var \Drupal\Core\Routing\RouteBuilderInterface
*/
protected $routerBuilder;
/**
* Constructs the RestConfigSubscriber.
*
* @param \Drupal\Core\Routing\RouteBuilderInterface $router_builder
* The router builder service.
*/
public function __construct(RouteBuilderInterface $router_builder) {
$this->routerBuilder = $router_builder;
}
/**
* Informs the router builder a rebuild is needed when necessary.
*
* @param \Drupal\Core\Config\ConfigCrudEvent $event
* The Event to process.
*/
public function onSave(ConfigCrudEvent $event) {
$saved_config = $event->getConfig();
// @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
if ($saved_config->getName() === 'rest.settings' && $event->isChanged('bc_entity_resource_permissions')) {
$this->routerBuilder->setRebuildNeeded();
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[ConfigEvents::SAVE][] = ['onSave'];
return $events;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\ConfigurableLinkManagerInterface as MovedConfigurableLinkManagerInterface;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
interface ConfigurableLinkManagerInterface extends MovedConfigurableLinkManagerInterface {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\LinkManager as MovedLinkManager;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
class LinkManager extends MovedLinkManager implements LinkManagerInterface {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\LinkManagerBase as MovedLinkManagerBase;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
abstract class LinkManagerBase extends MovedLinkManagerBase {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\LinkManagerInterface as MovedLinkManagerInterface;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
interface LinkManagerInterface extends MovedLinkManagerInterface {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\RelationLinkManager as MovedLinkRelationManager;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
class RelationLinkManager extends MovedLinkRelationManager implements RelationLinkManagerInterface {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\RelationLinkManagerInterface as MovedRelationLinkManagerInterface;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
interface RelationLinkManagerInterface extends MovedRelationLinkManagerInterface {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\TypeLinkManager as MovedTypeLinkManager;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
class TypeLinkManager extends MovedTypeLinkManager implements TypeLinkManagerInterface {}
@@ -0,0 +1,13 @@
<?php
namespace Drupal\rest\LinkManager;
use Drupal\hal\LinkManager\TypeLinkManagerInterface as MovedTypeLinkManagerInterface;
/**
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. This has
* been moved to the hal module. This exists solely for BC.
*
* @see https://www.drupal.org/node/2830467
*/
interface TypeLinkManagerInterface extends MovedTypeLinkManagerInterface {}
@@ -0,0 +1,34 @@
<?php
namespace Drupal\rest;
use Symfony\Component\HttpFoundation\Response;
/**
* A response that does not contain cacheability metadata.
*
* Used when resources are modified by a request: responses to unsafe requests
* (POST/PATCH/DELETE) can never be cached.
*
* @see \Drupal\rest\ResourceResponse
*/
class ModifiedResourceResponse extends Response implements ResourceResponseInterface {
use ResourceResponseTrait;
/**
* Constructor for ModifiedResourceResponse 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 = []) {
$this->responseData = $data;
parent::__construct('', $status, $headers);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Drupal\rest\PathProcessor;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Path processor to maintain BC for entity REST resource URLs from Drupal 8.0.
*/
class PathProcessorEntityResourceBC implements InboundPathProcessorInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Creates a new PathProcessorEntityResourceBC instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function processInbound($path, Request $request) {
if ($request->getMethod() === 'POST' && strpos($path, '/entity/') === 0) {
$parts = explode('/', $path);
$entity_type_id = array_pop($parts);
// Until Drupal 8.3, no entity types specified a link template for the
// 'create' link relation type. As of Drupal 8.3, all core content entity
// types provide this link relation type. This inbound path processor
// provides automatic backwards compatibility: it allows both the old
// default from \Drupal\rest\Plugin\rest\resource\EntityResource, i.e.
// "/entity/{entity_type}" and the link template specified in a particular
// entity type. The former is rewritten to the latter
// specific one if it exists.
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
if ($entity_type->hasLinkTemplate('create')) {
return $entity_type->getLinkTemplate('create');
}
}
return $path;
}
}
@@ -0,0 +1,108 @@
<?php
namespace Drupal\rest\Plugin\Deriver;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a resource plugin definition for every entity type.
*
* @see \Drupal\rest\Plugin\rest\resource\EntityResource
*/
class EntityDeriver implements ContainerDeriverInterface {
use DeprecatedServicePropertyTrait;
/**
* {@inheritdoc}
*/
protected $deprecatedProperties = ['entityManager' => 'entity.manager'];
/**
* List of derivative definitions.
*
* @var array
*/
protected $derivatives;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs an EntityDeriver object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
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];
}
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
if (!isset($this->derivatives)) {
// Add in the default plugin configuration and the resource type.
foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
if ($entity_type->isInternal()) {
continue;
}
$this->derivatives[$entity_type_id] = [
'id' => 'entity:' . $entity_type_id,
'entity_type' => $entity_type_id,
'serialization_class' => $entity_type->getClass(),
'label' => $entity_type->getLabel(),
];
$default_uris = [
'canonical' => "/entity/$entity_type_id/" . '{' . $entity_type_id . '}',
'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;
}
}
@@ -0,0 +1,237 @@
<?php
namespace Drupal\rest\Plugin;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Routing\BcRoute;
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.
*
* Note that this base class' implementation of the permissions() method
* generates a permission for every method for a resource. If your resource
* already has its own access control mechanism, you should opt out from this
* default permissions() method by overriding it.
*
* @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 = [];
/**
* 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 = [];
$definition = $this->getPluginDefinition();
foreach ($this->availableMethods() as $method) {
$lowered_method = strtolower($method);
$permissions["restful $lowered_method $this->pluginId"] = [
'title' => $this->t('Access @method on %label resource', ['@method' => $method, '%label' => $definition['label']]),
];
}
return $permissions;
}
/**
* {@inheritdoc}
*/
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']['create']) ? $definition['uri_paths']['create'] : '/' . strtr($this->pluginId, ':', '/');
// BC: the REST module originally created the POST URL for a resource by
// reading the 'https://www.drupal.org/link-relations/create' URI path from
// the plugin annotation. For consistency with entity type definitions, that
// then changed to reading the 'create' URI path. For any REST Resource
// plugins that were using the old mechanism, we continue to support that.
if (!isset($definition['uri_paths']['create']) && isset($definition['uri_paths']['https://www.drupal.org/link-relations/create'])) {
@trigger_error('The "https://www.drupal.org/link-relations/create" string as a RestResource plugin annotation URI path key is deprecated in Drupal 8.4.0, now a valid link relation type name must be specified, so "create" must be specified instead before Drupal 9.0.0. See https://www.drupal.org/node/2737401.', E_USER_DEPRECATED);
$create_path = $definition['uri_paths']['https://www.drupal.org/link-relations/create'];
}
$route_name = strtr($this->pluginId, ':', '.');
$methods = $this->availableMethods();
foreach ($methods as $method) {
$path = $method === 'POST'
? $create_path
: $canonical_path;
$route = $this->getBaseRoute($path, $method);
// Note that '_format' and '_content_type_format' route requirements are
// added in ResourceRoutes::getRoutesForResourceConfig().
$collection->add("$route_name.$method", $route);
// BC: the REST module originally created per-format GET routes, instead
// of a single route. To minimize the surface of this BC layer, this uses
// route definitions that are as empty as possible, plus an outbound route
// processor.
// @see \Drupal\rest\RouteProcessor\RestResourceGetRouteProcessorBC
if ($method === 'GET' || $method === 'HEAD') {
foreach ($this->serializerFormats as $format_name) {
$collection->add("$route_name.$method.$format_name", (new BcRoute())->setRequirement('_format', $format_name));
}
}
}
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 [
'HEAD',
'GET',
'POST',
'PUT',
'DELETE',
'TRACE',
'OPTIONS',
'CONNECT',
'PATCH',
];
}
/**
* {@inheritdoc}
*/
public function availableMethods() {
$methods = $this->requestMethods();
$available = [];
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;
}
/**
* Gets the base route for a particular method.
*
* @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) {
return new Route($canonical_path, [
'_controller' => 'Drupal\rest\RequestHandler::handle',
],
$this->getBaseRouteRequirements($method),
[],
'',
[],
// The HTTP method is a requirement for this route.
[$method]
);
}
/**
* Gets the base route requirements for a particular method.
*
* @param $method
* The HTTP method to be used for the route.
*
* @return array
* An array of requirements for parameters.
*/
protected function getBaseRouteRequirements($method) {
$lower_method = strtolower($method);
// Every route MUST have requirements that result in the access manager
// having access checks to check. If it does not, the route is made
// inaccessible. So, we default to granting access to everyone. If a
// permission exists, then we add that below. The access manager requires
// that ALL access checks must grant access, so this still results in
// correct behavior.
$requirements = [
'_access' => 'TRUE',
];
// Only specify route requirements if the default permission exists. For any
// more advanced route definition, resource plugins extending this base
// class must override this method.
$permission = "restful $lower_method $this->pluginId";
if (isset($this->permissions()[$permission])) {
$requirements['_permission'] = $permission;
}
return $requirements;
}
}
@@ -0,0 +1,53 @@
<?php
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.
*
* It is not required for a resource plugin to specify permissions: if they
* have their own access control mechanism, they can use that, and return the
* empty array.
*
* @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();
}
@@ -0,0 +1,52 @@
<?php
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');
}
/**
* {@inheritdoc}
*
* @deprecated in Drupal 8.2.0.
* Use Drupal\rest\Plugin\Type\ResourcePluginManager::createInstance()
* instead.
*
* @see https://www.drupal.org/node/2874934
*/
public function getInstance(array $options) {
if (isset($options['id'])) {
return $this->createInstance($options['id']);
}
}
}
@@ -0,0 +1,473 @@
<?php
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Config\Entity\ConfigEntityType;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Routing\AccessAwareRouterInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\rest\ModifiedResourceResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Represents entities as resources.
*
* @see \Drupal\rest\Plugin\Deriver\EntityDeriver
*
* @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}",
* "create" = "/entity/{entity_type}"
* }
* )
*/
class EntityResource extends ResourceBase implements DependentPluginInterface {
use EntityResourceValidationTrait;
use EntityResourceAccessTrait;
/**
* The entity type targeted by this resource.
*
* @var \Drupal\Core\Entity\EntityTypeInterface
*/
protected $entityType;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The link relation type manager used to create HTTP header links.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $linkRelationTypeManager;
/**
* Constructs a Drupal\rest\Plugin\rest\resource\EntityResource object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager
* @param array $serializer_formats
* The available serialization formats.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Component\Plugin\PluginManagerInterface $link_relation_type_manager
* The link relation type manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, $serializer_formats, LoggerInterface $logger, ConfigFactoryInterface $config_factory, PluginManagerInterface $link_relation_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
$this->entityType = $entity_type_manager->getDefinition($plugin_definition['entity_type']);
$this->configFactory = $config_factory;
$this->linkRelationTypeManager = $link_relation_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->getParameter('serializer.formats'),
$container->get('logger.factory')->get('rest'),
$container->get('config.factory'),
$container->get('plugin.manager.link_relation_type')
);
}
/**
* Responds to entity GET requests.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object.
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
*
* @return \Drupal\rest\ResourceResponse
* The response containing the entity with its accessible fields.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function get(EntityInterface $entity, Request $request) {
$response = new ResourceResponse($entity, 200);
// @todo Either remove the line below or remove this todo in https://www.drupal.org/project/drupal/issues/2973356
$response->addCacheableDependency($request->attributes->get(AccessAwareRouterInterface::ACCESS_RESULT));
$response->addCacheableDependency($entity);
if ($entity instanceof FieldableEntityInterface) {
foreach ($entity as $field_name => $field) {
/** @var \Drupal\Core\Field\FieldItemListInterface $field */
$field_access = $field->access('view', NULL, TRUE);
$response->addCacheableDependency($field_access);
if (!$field_access->isAllowed()) {
$entity->set($field_name, NULL);
}
}
}
$this->addLinkHeaders($entity, $response);
return $response;
}
/**
* Responds to entity POST requests and saves the new entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Drupal\rest\ModifiedResourceResponse
* 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.');
}
$entity_access = $entity->access('create', NULL, TRUE);
if (!$entity_access->isAllowed()) {
throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'create'));
}
$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');
}
$this->checkEditFieldAccess($entity);
// Validate the received data before saving.
$this->validate($entity);
try {
$entity->save();
$this->logger->notice('Created entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
// 201 Created responses return the newly created entity in the response
// body. These responses are not cacheable, so we add no cacheability
// metadata here.
$headers = [];
if (in_array('canonical', $entity->uriRelationships(), TRUE)) {
$url = $entity->toUrl('canonical', ['absolute' => TRUE])->toString(TRUE);
$headers['Location'] = $url->getGeneratedUrl();
}
return new ModifiedResourceResponse($entity, 201, $headers);
}
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\ModifiedResourceResponse
* 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');
}
// Overwrite the received fields.
// @todo Remove $changed_fields in https://www.drupal.org/project/drupal/issues/2862574.
$changed_fields = [];
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.
// @todo Remove in https://www.drupal.org/project/drupal/issues/2933408.
if ($entity->getEntityType()->hasKey('langcode') && $field_name === $entity->getEntityType()->getKey('langcode') && $field->isEmpty()) {
continue;
}
if ($this->checkPatchFieldAccess($original_entity->get($field_name), $field)) {
$changed_fields[] = $field_name;
$original_entity->set($field_name, $field->getValue());
}
}
// If no fields are changed, we can send a response immediately!
if (empty($changed_fields)) {
return new ModifiedResourceResponse($original_entity, 200);
}
// Validate the received data before saving.
$this->validate($original_entity, $changed_fields);
try {
$original_entity->save();
$this->logger->notice('Updated entity %type with ID %id.', ['%type' => $original_entity->getEntityTypeId(), '%id' => $original_entity->id()]);
// Return the updated entity in the response body.
return new ModifiedResourceResponse($original_entity, 200);
}
catch (EntityStorageException $e) {
throw new HttpException(500, 'Internal Server Error', $e);
}
}
/**
* Checks whether the given field should be PATCHed.
*
* @param \Drupal\Core\Field\FieldItemListInterface $original_field
* The original (stored) value for the field.
* @param \Drupal\Core\Field\FieldItemListInterface $received_field
* The received value for the field.
*
* @return bool
* Whether the field should be PATCHed or not.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Thrown when the user sending the request is not allowed to update the
* field. Only thrown when the user could not abuse this information to
* determine the stored value.
*
* @internal
*/
protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) {
// The user might not have access to edit the field, but still needs to
// submit the current field value as part of the PATCH request. For
// example, the entity keys required by denormalizers. Therefore, if the
// received value equals the stored value, return FALSE without throwing an
// exception. But only for fields that the user has access to view, because
// the user has no legitimate way of knowing the current value of fields
// that they are not allowed to view, and we must not make the presence or
// absence of a 403 response a way to find that out.
if ($original_field->access('view') && $original_field->equals($received_field)) {
return FALSE;
}
// If the user is allowed to edit the field, it is always safe to set the
// received value. We may be setting an unchanged value, but that is ok.
$field_edit_access = $original_field->access('edit', NULL, TRUE);
if ($field_edit_access->isAllowed()) {
return TRUE;
}
// It's helpful and safe to let the user know when they are not allowed to
// update a field.
$field_name = $received_field->getName();
$error_message = "Access denied on updating field '$field_name'.";
if ($field_edit_access instanceof AccessResultReasonInterface) {
$reason = $field_edit_access->getReason();
if ($reason) {
$error_message .= ' ' . $reason;
}
}
throw new AccessDeniedHttpException($error_message);
}
/**
* Responds to entity DELETE requests.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object.
*
* @return \Drupal\rest\ModifiedResourceResponse
* The HTTP response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function delete(EntityInterface $entity) {
try {
$entity->delete();
$this->logger->notice('Deleted entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
// DELETE responses have an empty body.
return new ModifiedResourceResponse(NULL, 204);
}
catch (EntityStorageException $e) {
throw new HttpException(500, 'Internal Server Error', $e);
}
}
/**
* Generates a fallback access denied message, when no specific reason is set.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object.
* @param string $operation
* The disallowed entity operation.
*
* @return string
* The proper message to display in the AccessDeniedHttpException.
*/
protected function generateFallbackAccessDeniedMessage(EntityInterface $entity, $operation) {
$message = "You are not authorized to {$operation} this {$entity->getEntityTypeId()} entity";
if ($entity->bundle() !== $entity->getEntityTypeId()) {
$message .= " of bundle {$entity->bundle()}";
}
return "{$message}.";
}
/**
* {@inheritdoc}
*/
public function permissions() {
// @see https://www.drupal.org/node/2664780
if ($this->configFactory->get('rest.settings')->get('bc_entity_resource_permissions')) {
// The default Drupal 8.0.x and 8.1.x behavior.
return parent::permissions();
}
else {
// The default Drupal 8.2.x behavior.
return [];
}
}
/**
* {@inheritdoc}
*/
protected function getBaseRoute($canonical_path, $method) {
$route = parent::getBaseRoute($canonical_path, $method);
switch ($method) {
case 'GET':
$route->setRequirement('_entity_access', $this->entityType->id() . '.view');
break;
case 'POST':
$route->setRequirement('_entity_create_any_access', $this->entityType->id());
$route->setOption('_ignore_create_bundle_access', TRUE);
break;
case 'PATCH':
$route->setRequirement('_entity_access', $this->entityType->id() . '.update');
break;
case 'DELETE':
$route->setRequirement('_entity_access', $this->entityType->id() . '.delete');
break;
}
$definition = $this->getPluginDefinition();
$parameters = $route->getOption('parameters') ?: [];
$parameters[$definition['entity_type']]['type'] = 'entity:' . $definition['entity_type'];
$route->setOption('parameters', $parameters);
return $route;
}
/**
* {@inheritdoc}
*/
public function availableMethods() {
$methods = parent::availableMethods();
if ($this->isConfigEntityResource()) {
// Currently only GET is supported for Config Entities.
// @todo Remove when supported https://www.drupal.org/node/2300677
$unsupported_methods = ['POST', 'PUT', 'DELETE', 'PATCH'];
$methods = array_diff($methods, $unsupported_methods);
}
return $methods;
}
/**
* Checks if this resource is for a Config Entity.
*
* @return bool
* TRUE if the entity is a Config Entity, FALSE otherwise.
*/
protected function isConfigEntityResource() {
return $this->entityType instanceof ConfigEntityType;
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
if (isset($this->entityType)) {
return ['module' => [$this->entityType->getProvider()]];
}
}
/**
* Adds link headers to a response.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param \Symfony\Component\HttpFoundation\Response $response
* The response.
*
* @see https://tools.ietf.org/html/rfc5988#section-5
*/
protected function addLinkHeaders(EntityInterface $entity, Response $response) {
foreach ($entity->uriRelationships() as $relation_name) {
if ($this->linkRelationTypeManager->hasDefinition($relation_name)) {
/** @var \Drupal\Core\Http\LinkRelationTypeInterface $link_relation_type */
$link_relation_type = $this->linkRelationTypeManager->createInstance($relation_name);
$generator_url = $entity->toUrl($relation_name)
->setAbsolute(TRUE)
->toString(TRUE);
if ($response instanceof CacheableResponseInterface) {
$response->addCacheableDependency($generator_url);
}
$uri = $generator_url->getGeneratedUrl();
$relationship = $link_relation_type->isRegistered()
? $link_relation_type->getRegisteredName()
: $link_relation_type->getExtensionUri();
$link_header = '<' . $uri . '>; rel="' . $relationship . '"';
$response->headers->set('Link', $link_header, FALSE);
}
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Core\Entity\EntityInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @internal
* @todo Consider making public in https://www.drupal.org/node/2300677
*/
trait EntityResourceAccessTrait {
/**
* Performs edit access checks for fields.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose fields edit access should be checked for.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Throws access denied when the user does not have permissions to edit a
* field.
*/
protected function checkEditFieldAccess(EntityInterface $entity) {
// 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("Access denied on creating field '$field_name'.");
}
}
}
}
@@ -0,0 +1,59 @@
<?php
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
* @todo Consider making public in https://www.drupal.org/node/2300677
*/
trait EntityResourceValidationTrait {
/**
* Verifies that an entity does not violate any validation constraints.
*
* The validation errors will be filtered to not include fields to which the
* current user does not have access and if $fields_to_validate is provided
* will only include fields in that array.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to validate.
* @param string[] $fields_to_validate
* (optional) An array of field names. If specified, filters the violations
* list to include only this set of fields.
*
* @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
* If validation errors are found.
*/
protected function validate(EntityInterface $entity, array $fields_to_validate = []) {
// @todo Update this check in https://www.drupal.org/node/2300677.
if (!$entity instanceof FieldableEntityInterface) {
return;
}
$violations = $entity->validate();
// Remove violations of inaccessible fields as they cannot stem from our
// changes.
$violations->filterByFieldAccess();
if ($fields_to_validate) {
// Filter violations by explicitly provided array of field names.
$violations->filterByFields(array_diff(array_keys($entity->getFieldDefinitions()), $fields_to_validate));
}
if ($violations->count() > 0) {
$message = "Unprocessable Entity: validation failed.\n";
foreach ($violations as $violation) {
// We strip every HTML from the error message to have a nicer to read
// message on REST responses.
$message .= $violation->getPropertyPath() . ': ' . PlainTextOutput::renderFromHtml($violation->getMessage()) . "\n";
}
throw new UnprocessableEntityHttpException($message);
}
}
}
@@ -0,0 +1,522 @@
<?php
namespace Drupal\rest\Plugin\views\display;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\State\StateInterface;
use Drupal\views\Plugin\views\display\ResponseDisplayPluginInterface;
use Drupal\views\Render\ViewsRenderPipelineMarkup;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\PathPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
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 {
/**
* {@inheritdoc}
*/
protected $usesAJAX = FALSE;
/**
* {@inheritdoc}
*/
protected $usesPager = FALSE;
/**
* {@inheritdoc}
*/
protected $usesMore = FALSE;
/**
* {@inheritdoc}
*/
protected $usesAreas = FALSE;
/**
* {@inheritdoc}
*/
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 = 'application/json';
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The collector of authentication providers.
*
* @var \Drupal\Core\Authentication\AuthenticationCollectorInterface
*/
protected $authenticationCollector;
/**
* The authentication providers, like 'cookie' and 'basic_auth'.
*
* @var string[]
*/
protected $authenticationProviderIds;
/**
* The authentication providers' modules, keyed by provider ID.
*
* Authentication providers like 'cookie' and 'basic_auth' are the array
* keys. The array values are the module names, e.g.:
* @code
* ['cookie' => 'user', 'basic_auth' => 'basic_auth']
* @endcode
*
* @deprecated in drupal:8.4.0 and is removed from drupal:9.0.0. see
* https://www.drupal.org/node/2825204.
*
* @var string[]
*/
protected $authenticationProviders;
/**
* The serialization format providers, keyed by format.
*
* @var string[]
*/
protected $formatProviders;
/**
* Constructs a RestExport 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 \Drupal\Core\Routing\RouteProviderInterface $route_provider
* The route provider.
* @param \Drupal\Core\State\StateInterface $state
* The state key value store.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
* @param string[] $authentication_providers
* The authentication providers, keyed by ID.
* @param string[] $serializer_format_providers
* The serialization format providers, keyed by format.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state, RendererInterface $renderer, array $authentication_providers, array $serializer_format_providers) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $route_provider, $state);
$this->renderer = $renderer;
// $authentication_providers as defined in
// \Drupal\Core\DependencyInjection\Compiler\AuthenticationProviderPass
// and as such it is an array, with authentication providers (cookie,
// basic_auth) as keys and modules providing those as values (user,
// basic_auth).
$this->authenticationProviderIds = array_keys($authentication_providers);
// For BC reasons we keep around authenticationProviders as before.
$this->authenticationProviders = $authentication_providers;
$this->formatProviders = $serializer_format_providers;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('router.route_provider'),
$container->get('state'),
$container->get('renderer'),
$container->getParameter('authentication_providers'),
$container->getParameter('serializer.format_providers')
);
}
/**
* {@inheritdoc}
*/
public function initDisplay(ViewExecutable $view, array &$display, array &$options = NULL) {
parent::initDisplay($view, $display, $options);
// If the default 'json' format is not selected as a format option in the
// view display, fallback to the first format available for the default.
if (!empty($options['style']['options']['formats']) && !isset($options['style']['options']['formats'][$this->getContentType()])) {
$default_format = reset($options['style']['options']['formats']);
$this->setContentType($default_format);
}
// Only use the requested content type if it's not 'html'. This allows
// still falling back to the default for things like views preview.
$request_content_type = $this->view->getRequest()->getRequestFormat();
if ($request_content_type !== 'html') {
$this->setContentType($request_content_type);
}
$this->setMimeType($this->view->getRequest()->getMimeType($this->getContentType()));
}
/**
* {@inheritdoc}
*/
public function getType() {
return 'data';
}
/**
* {@inheritdoc}
*/
public function usesExposed() {
return TRUE;
}
/**
* {@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;
}
/**
* Gets the auth options available.
*
* @return string[]
* An array to use as value for "#options" in the form element.
*/
public function getAuthOptions() {
return array_combine($this->authenticationProviderIds, $this->authenticationProviderIds);
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
// Options for REST authentication.
$options['auth'] = ['default' => []];
// 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);
// Authentication.
$auth = $this->getOption('auth') ? implode(', ', $this->getOption('auth')) : $this->t('No authentication is set');
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'] = [
'title' => $this->t('Path settings'),
'column' => 'second',
'build' => [
'#weight' => -10,
],
];
$options['path']['category'] = 'path';
$options['path']['title'] = $this->t('Path');
$options['auth'] = [
'category' => 'path',
'title' => $this->t('Authentication'),
'value' => views_ui_truncate($auth, 24),
];
// 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 buildOptionsForm(&$form, FormStateInterface $form_state) {
parent::buildOptionsForm($form, $form_state);
if ($form_state->get('section') === 'auth') {
$form['#title'] .= $this->t('The supported authentication methods for this view');
$form['auth'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Authentication methods'),
'#description' => $this->t('These are the supported authentication providers for this view. When this view is requested, the client will be forced to authenticate with one of the selected providers. Make sure you set the appropriate requirements at the <em>Access</em> section since the Authentication System will fallback to the anonymous user if it fails to authenticate. For example: require Access: Role | Authenticated User.'),
'#options' => $this->getAuthOptions(),
'#default_value' => $this->getOption('auth'),
];
}
}
/**
* {@inheritdoc}
*/
public function submitOptionsForm(&$form, FormStateInterface $form_state) {
parent::submitOptionsForm($form, $form_state);
if ($form_state->get('section') == 'auth') {
$this->setOption('auth', array_keys(array_filter($form_state->getValue('auth'))));
}
}
/**
* {@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']);
$formats = $style_plugin->getFormats();
// If there are no configured formats, add all formats that serialization
// is known to support.
if (!$formats) {
$formats = $this->getFormatOptions();
}
// Format as a string using pipes as a delimiter.
$route->setRequirement('_format', implode('|', $formats));
// Add authentication to the route if it was set. If no authentication was
// set, the default authentication will be used, which is cookie based by
// default.
$auth = $this->getOption('auth');
if (!empty($auth)) {
$route->setOption('_auth', $auth);
}
}
}
/**
* Determines whether the view overrides the given route.
*
* @param string $view_path
* The path of the view.
* @param \Symfony\Component\Routing\Route $view_route
* The route of the view.
* @param \Symfony\Component\Routing\Route $route
* The route itself.
*
* @return bool
* TRUE, when the view should override the given route.
*/
protected function overrideApplies($view_path, Route $view_route, Route $route) {
$route_formats = explode('|', $route->getRequirement('_format'));
$view_route_formats = explode('|', $view_route->getRequirement('_format'));
return $this->overrideAppliesPathAndMethod($view_path, $view_route, $route)
&& (!$route->hasRequirement('_format') || array_intersect($route_formats, $view_route_formats) != []);
}
/**
* {@inheritdoc}
*/
public static function buildResponse($view_id, $display_id, array $args = []) {
$build = static::buildBasicRenderable($view_id, $display_id, $args);
// Setup an empty response so headers can be added as needed during views
// rendering and processing.
$response = new CacheableResponse('', 200);
$build['#response'] = $response;
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$output = (string) $renderer->renderRoot($build);
$response->setContent($output);
$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 = [];
$build['#markup'] = $this->renderer->executeInRenderContext(new RenderContext(), function () {
return $this->view->style_plugin->render();
});
$this->view->element['#content_type'] = $this->getMimeType();
$this->view->element['#cache_properties'][] = '#content_type';
// Encode and wrap the output in a pre tag if this is for a live preview.
if (!empty($this->view->live_preview)) {
$build['#prefix'] = '<pre>';
$build['#plain_text'] = $build['#markup'];
$build['#suffix'] = '</pre>';
unset($build['#markup']);
}
else {
// This display plugin is 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,
// 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'] = ViewsRenderPipelineMarkup::create($build['#markup']);
}
parent::applyDisplayCacheabilityMetadata($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();
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
$dependencies += ['module' => []];
$dependencies['module'] = array_merge($dependencies['module'], array_filter(array_map(function ($provider) {
// During the update path the provider options might be wrong. This can
// happen when any update function, like block_update_8300() triggers a
// view to be saved.
return isset($this->authenticationProviderIds[$provider])
? $this->authenticationProviderIds[$provider]
: NULL;
}, $this->getOption('auth'))));
return $dependencies;
}
/**
* Returns an array of format options.
*
* @return string[]
* An array of format options. Both key and value are the same.
*/
protected function getFormatOptions() {
$formats = array_keys($this->formatProviders);
return array_combine($formats, $formats);
}
}
@@ -0,0 +1,177 @@
<?php
namespace Drupal\rest\Plugin\views\row;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\views\Entity\Render\EntityTranslationRenderTrait;
use Drupal\views\Plugin\views\row\RowPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* 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 {
use EntityTranslationRenderTrait;
use DeprecatedServicePropertyTrait;
/**
* {@inheritdoc}
*/
protected $deprecatedProperties = ['entityManager' => 'entity.manager'];
/**
* {@inheritdoc}
*/
protected $usesOptions = FALSE;
/**
* Contains the entity type of this row plugin instance.
*
* @var \Drupal\Core\Entity\EntityTypeInterface
*/
protected $entityType;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity repository service.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* The entity display repository.
*
* @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface
*/
protected $entityDisplayRepository;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* Constructs a new DataEntityRow 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 array $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository.
*/
public function __construct(array $configuration, $plugin_id, array $plugin_definition, EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, EntityRepositoryInterface $entity_repository = NULL) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->languageManager = $language_manager;
if (!$entity_repository) {
@trigger_error('Calling DataEntityRow::__construct() with the $entity_repository argument is supported in drupal:8.7.0 and will be required before drupal:9.0.0. See https://www.drupal.org/node/2549139.', E_USER_DEPRECATED);
$entity_repository = \Drupal::service('entity.repository');
}
$this->entityRepository = $entity_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('language_manager'),
$container->get('entity.repository')
);
}
/**
* {@inheritdoc}
*/
public function render($row) {
return $this->getEntityTranslation($row->_entity, $row);
}
/**
* {@inheritdoc}
*/
public function getEntityTypeId() {
return $this->view->getBaseEntityType()->id();
}
/**
* {@inheritdoc}
*/
protected function getEntityManager() {
// This relies on DeprecatedServicePropertyTrait to trigger a deprecation
// message in case it is accessed.
return $this->entityManager;
}
/**
* {@inheritdoc}
*/
protected function getEntityTypeManager() {
return $this->entityTypeManager;
}
/**
* {@inheritdoc}
*/
protected function getEntityRepository() {
return $this->entityRepository;
}
/**
* {@inheritdoc}
*/
protected function getLanguageManager() {
return $this->languageManager;
}
/**
* {@inheritdoc}
*/
protected function getView() {
return $this->view;
}
/**
* {@inheritdoc}
*/
public function query() {
parent::query();
$this->getEntityTranslationRenderer()->query($this->view->getQuery());
}
}
@@ -0,0 +1,196 @@
<?php
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 {
/**
* {@inheritdoc}
*/
protected $usesFields = TRUE;
/**
* Stores an array of prepared field aliases from options.
*
* @var array
*/
protected $replacementAliases = [];
/**
* Stores an array of options to determine if the raw field output is used.
*
* @var array
*/
protected $rawOutputOptions = [];
/**
* {@inheritdoc}
*/
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);
}
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['field_options'] = ['default' => []];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
parent::buildOptionsForm($form, $form_state);
$form['field_options'] = [
'#type' => 'table',
'#header' => [$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) {
// Don't show the field if it has been excluded.
if (!empty($field['exclude'])) {
continue;
}
$form['field_options'][$id]['field'] = [
'#markup' => $id,
];
$form['field_options'][$id]['alias'] = [
'#title' => $this->t('Alias for @id', ['@id' => $id]),
'#title_display' => 'invisible',
'#type' => 'textfield',
'#default_value' => isset($options[$id]['alias']) ? $options[$id]['alias'] : '',
'#element_validate' => [[$this, 'validateAliasName']],
];
$form['field_options'][$id]['raw_output'] = [
'#title' => $this->t('Raw output for @id', ['@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.'));
}
}
/**
* {@inheritdoc}
*/
public function validateOptionsForm(&$form, FormStateInterface $form_state) {
// Collect an array of aliases to validate.
$aliases = static::extractFromOptionsArray('alias', $form_state->getValue(['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'));
}
}
/**
* {@inheritdoc}
*/
public function render($row) {
$output = [];
foreach ($this->view->field as $id => $field) {
// If the raw output option has been set, just get the raw value.
if (!empty($this->rawOutputOptions[$id])) {
$value = $field->getValue($row);
}
// Otherwise, get rendered field.
else {
// Advanced render for token replacement.
$markup = $field->advancedRender($row);
// Post render to support uncacheable fields.
$field->postRender($row, $markup);
$value = $field->last_render;
}
// Omit excluded fields from the rendered output.
if (empty($field->options['exclude'])) {
$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);
}
}
@@ -0,0 +1,205 @@
<?php
namespace Drupal\rest\Plugin\views\style;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Form\FormStateInterface;
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 CacheableDependencyInterface {
/**
* {@inheritdoc}
*/
protected $usesRowPlugin = TRUE;
/**
* {@inheritdoc}
*/
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 = [];
/**
* The serialization format providers, keyed by format.
*
* @var string[]
*/
protected $formatProviders;
/**
* {@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'),
$container->getParameter('serializer.format_providers')
);
}
/**
* Constructs a Plugin object.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, SerializerInterface $serializer, array $serializer_formats, array $serializer_format_providers) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->definition = $plugin_definition + $configuration;
$this->serializer = $serializer;
$this->formats = $serializer_formats;
$this->formatProviders = $serializer_format_providers;
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['formats'] = ['default' => []];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
parent::buildOptionsForm($form, $form_state);
$form['formats'] = [
'#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' => $this->getFormatOptions(),
'#default_value' => $this->options['formats'],
];
}
/**
* {@inheritdoc}
*/
public function submitOptionsForm(&$form, FormStateInterface $form_state) {
parent::submitOptionsForm($form, $form_state);
$formats = $form_state->getValue(['style_options', 'formats']);
$form_state->setValue(['style_options', 'formats'], array_filter($formats));
}
/**
* {@inheritdoc}
*/
public function render() {
$rows = [];
// 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_index => $row) {
$this->view->row_index = $row_index;
$rows[] = $this->view->rowPlugin->render($row);
}
unset($this->view->row_index);
// 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, ['views_style_plugin' => $this]);
}
/**
* 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 getCacheMaxAge() {
return Cache::PERMANENT;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return ['request_format'];
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return [];
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
$formats = $this->getFormats();
$providers = array_intersect_key($this->formatProviders, array_flip($formats));
// The plugin always uses services from the serialization module.
$providers[] = 'serialization';
$dependencies += ['module' => []];
$dependencies['module'] = array_merge($dependencies['module'], $providers);
return $dependencies;
}
/**
* Returns an array of format options
*
* @return string[]
* An array of format options. Both key and value are the same.
*/
protected function getFormatOptions() {
$formats = array_keys($this->formatProviders);
return array_combine($formats, $formats);
}
}
@@ -0,0 +1,347 @@
<?php
namespace Drupal\rest;
use Drupal\Component\Utility\ArgumentsResolver;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\rest\Plugin\ResourceInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Acts as intermediate request forwarder for resource plugins.
*
* @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
*/
class RequestHandler implements ContainerInjectionInterface {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The serializer.
*
* @var \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface
*/
protected $serializer;
/**
* Creates a new RequestHandler instance.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface $serializer
* The serializer.
*/
public function __construct(ConfigFactoryInterface $config_factory, SerializerInterface $serializer) {
$this->configFactory = $config_factory;
$this->serializer = $serializer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('serializer')
);
}
/**
* Handles a REST API request.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
* @param \Drupal\rest\RestResourceConfigInterface $_rest_resource_config
* The REST resource config entity.
*
* @return \Drupal\rest\ResourceResponseInterface|\Symfony\Component\HttpFoundation\Response
* The REST resource response.
*/
public function handle(RouteMatchInterface $route_match, Request $request, RestResourceConfigInterface $_rest_resource_config) {
$resource = $_rest_resource_config->getResourcePlugin();
$unserialized = $this->deserialize($route_match, $request, $resource);
$response = $this->delegateToRestResourcePlugin($route_match, $request, $unserialized, $resource);
return $this->prepareResponse($response, $_rest_resource_config);
}
/**
* Handles a REST API request without deserializing the request body.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
* @param \Drupal\rest\RestResourceConfigInterface $_rest_resource_config
* The REST resource config entity.
*
* @return \Symfony\Component\HttpFoundation\Response|\Drupal\rest\ResourceResponseInterface
* The REST resource response.
*/
public function handleRaw(RouteMatchInterface $route_match, Request $request, RestResourceConfigInterface $_rest_resource_config) {
$resource = $_rest_resource_config->getResourcePlugin();
$response = $this->delegateToRestResourcePlugin($route_match, $request, NULL, $resource);
return $this->prepareResponse($response, $_rest_resource_config);
}
/**
* Prepares the REST resource response.
*
* @param \Drupal\rest\ResourceResponseInterface $response
* The REST resource response.
* @param \Drupal\rest\RestResourceConfigInterface $resource_config
* The REST resource config entity.
*
* @return \Drupal\rest\ResourceResponseInterface
* The prepared REST resource response.
*/
protected function prepareResponse($response, RestResourceConfigInterface $resource_config) {
if ($response instanceof CacheableResponseInterface) {
$response->addCacheableDependency($resource_config);
// Add global rest settings config's cache tag, for BC flags.
// @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
// @see \Drupal\rest\EventSubscriber\RestConfigSubscriber
// @todo Remove in https://www.drupal.org/node/2893804
$response->addCacheableDependency($this->configFactory->get('rest.settings'));
}
return $response;
}
/**
* Gets the normalized HTTP request method of the matched route.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*
* @return string
* The normalized HTTP request method.
*/
protected static function getNormalizedRequestMethod(RouteMatchInterface $route_match) {
// Symfony is built to transparently map HEAD requests to a GET request. In
// the case of the REST module's RequestHandler though, we essentially have
// our own light-weight routing system on top of the Drupal/symfony routing
// system. So, we have to respect the decision that the routing system made:
// we look not at the request method, but at the route's method. All REST
// routes are guaranteed to have _method set.
// Response::prepare() will transform it to a HEAD response at the very last
// moment.
// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
// @see \Symfony\Component\Routing\Matcher\UrlMatcher::matchCollection()
// @see \Symfony\Component\HttpFoundation\Response::prepare()
$method = strtolower($route_match->getRouteObject()->getMethods()[0]);
assert(count($route_match->getRouteObject()->getMethods()) === 1);
return $method;
}
/**
* Deserializes request body, if any.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
* @param \Drupal\rest\Plugin\ResourceInterface $resource
* The REST resource plugin.
*
* @return array|null
* An object normalization, ikf there is a valid request body. NULL if there
* is no request body.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown if the request body cannot be decoded.
* @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
* Thrown if the request body cannot be denormalized.
*/
protected function deserialize(RouteMatchInterface $route_match, Request $request, ResourceInterface $resource) {
// Deserialize incoming data if available.
$received = $request->getContent();
$unserialized = NULL;
if (!empty($received)) {
$method = static::getNormalizedRequestMethod($route_match);
$format = $request->getContentType();
$definition = $resource->getPluginDefinition();
// First decode the request data. We can then determine if the
// serialized data was malformed.
try {
$unserialized = $this->serializer->decode($received, $format, ['request_method' => $method]);
}
catch (UnexpectedValueException $e) {
// If an exception was thrown at this stage, there was a problem
// decoding the data. Throw a 400 http exception.
throw new BadRequestHttpException($e->getMessage());
}
// Then attempt to denormalize if there is a serialization class.
if (!empty($definition['serialization_class'])) {
try {
$unserialized = $this->serializer->denormalize($unserialized, $definition['serialization_class'], $format, ['request_method' => $method]);
}
// These two serialization exception types mean there was a problem
// with the structure of the decoded data and it's not valid.
catch (UnexpectedValueException $e) {
throw new UnprocessableEntityHttpException($e->getMessage());
}
catch (InvalidArgumentException $e) {
throw new UnprocessableEntityHttpException($e->getMessage());
}
}
}
return $unserialized;
}
/**
* Delegates an incoming request to the appropriate REST resource plugin.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request object.
* @param mixed|null $unserialized
* The unserialized request body, if any.
* @param \Drupal\rest\Plugin\ResourceInterface $resource
* The REST resource plugin.
*
* @return \Symfony\Component\HttpFoundation\Response|\Drupal\rest\ResourceResponseInterface
* The REST resource response.
*/
protected function delegateToRestResourcePlugin(RouteMatchInterface $route_match, Request $request, $unserialized, ResourceInterface $resource) {
$method = static::getNormalizedRequestMethod($route_match);
// Determine the request parameters that should be passed to the resource
// plugin.
$argument_resolver = $this->createArgumentResolver($route_match, $unserialized, $request);
try {
$arguments = $argument_resolver->getArguments([$resource, $method]);
}
catch (\RuntimeException $exception) {
@trigger_error('Passing in arguments the legacy way is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Provide the right parameter names in the method, similar to controllers. See https://www.drupal.org/node/2894819', E_USER_DEPRECATED);
$arguments = $this->getLegacyParameters($route_match, $unserialized, $request);
}
// Invoke the operation on the resource plugin.
return call_user_func_array([$resource, $method], $arguments);
}
/**
* Creates an argument resolver, containing all REST parameters.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param mixed $unserialized
* The unserialized data.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Drupal\Component\Utility\ArgumentsResolver
* An instance of the argument resolver containing information like the
* 'entity' we process and the 'unserialized' content from the request body.
*/
protected function createArgumentResolver(RouteMatchInterface $route_match, $unserialized, Request $request) {
$route = $route_match->getRouteObject();
// Defaults for the parameters defined on the route object need to be added
// to the raw arguments.
$raw_route_arguments = $route_match->getRawParameters()->all() + $route->getDefaults();
$route_arguments = $route_match->getParameters()->all();
$upcasted_route_arguments = $route_arguments;
// For request methods that have request bodies, ResourceInterface plugin
// methods historically receive the unserialized request body as the N+1th
// method argument, where N is the number of route parameters specified on
// the accompanying route. To be able to use the argument resolver, which is
// not based on position but on name and typehint, specify commonly used
// names here. Similarly, those methods receive the original stored object
// as the first method argument.
$route_arguments_entity = NULL;
// Try to find a parameter which is an entity.
foreach ($route_arguments as $value) {
if ($value instanceof EntityInterface) {
$route_arguments_entity = $value;
break;
}
}
if (in_array($request->getMethod(), ['PATCH', 'POST'], TRUE)) {
if (is_object($unserialized)) {
$upcasted_route_arguments['entity'] = $unserialized;
$upcasted_route_arguments['data'] = $unserialized;
$upcasted_route_arguments['unserialized'] = $unserialized;
}
else {
$raw_route_arguments['data'] = $unserialized;
$raw_route_arguments['unserialized'] = $unserialized;
}
$upcasted_route_arguments['original_entity'] = $route_arguments_entity;
}
else {
$upcasted_route_arguments['entity'] = $route_arguments_entity;
}
// Parameters which are not defined on the route object, but still are
// essential for access checking are passed as wildcards to the argument
// resolver.
$wildcard_arguments = [$route, $route_match];
$wildcard_arguments[] = $request;
if (isset($unserialized)) {
$wildcard_arguments[] = $unserialized;
}
return new ArgumentsResolver($raw_route_arguments, $upcasted_route_arguments, $wildcard_arguments);
}
/**
* Provides the parameter usable without an argument resolver.
*
* This creates an list of parameters in a statically defined order.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match
* @param mixed $unserialized
* The unserialized data.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @deprecated in drupal:8.4.0 and is removed from drupal:9.0.0. Use the
* argument resolver method instead, see ::createArgumentResolver().
*
* @see https://www.drupal.org/node/2894819
*
* @return array
* An array of parameters.
*/
protected function getLegacyParameters(RouteMatchInterface $route_match, $unserialized, Request $request) {
$route_parameters = $route_match->getParameters();
$parameters = [];
// Filter out all internal parameters starting with "_".
foreach ($route_parameters as $key => $parameter) {
if (substr((string) $key, 0, 1) !== '_') {
$parameters[] = $parameter;
}
}
return array_merge($parameters, [$unserialized, $request]);
}
}
@@ -0,0 +1,39 @@
<?php
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.
*
* @see \Drupal\rest\ModifiedResourceResponse
*/
class ResourceResponse extends Response implements CacheableResponseInterface, ResourceResponseInterface {
use CacheableResponseTrait;
use ResourceResponseTrait;
/**
* 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 = []) {
$this->responseData = $data;
parent::__construct('', $status, $headers);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Drupal\rest;
/**
* Defines a common interface for resource responses.
*/
interface ResourceResponseInterface {
/**
* Returns response data that should be serialized.
*
* @return mixed
* Response data that should be serialized.
*/
public function getResponseData();
}
@@ -0,0 +1,24 @@
<?php
namespace Drupal\rest;
trait ResourceResponseTrait {
/**
* Response data that should be serialized.
*
* @var mixed
*/
protected $responseData;
/**
* Returns response data that should be serialized.
*
* @return mixed
* Response data that should be serialized.
*/
public function getResponseData() {
return $this->responseData;
}
}
@@ -0,0 +1,65 @@
<?php
namespace Drupal\rest;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
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 REST resource config storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $resourceConfigStorage;
/**
* Constructs a new RestPermissions instance.
*
* @param \Drupal\rest\Plugin\Type\ResourcePluginManager $rest_plugin_manager
* The rest resource plugin manager.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(ResourcePluginManager $rest_plugin_manager, EntityTypeManagerInterface $entity_type_manager) {
$this->restPluginManager = $rest_plugin_manager;
$this->resourceConfigStorage = $entity_type_manager->getStorage('rest_resource_config');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('plugin.manager.rest'), $container->get('entity_type.manager'));
}
/**
* Returns an array of REST permissions.
*
* @return array
*/
public function permissions() {
$permissions = [];
/** @var \Drupal\rest\RestResourceConfigInterface[] $resource_configs */
$resource_configs = $this->resourceConfigStorage->loadMultiple();
foreach ($resource_configs as $resource_config) {
$plugin = $resource_config->getResourcePlugin();
$permissions = array_merge($permissions, $plugin->permissions());
}
return $permissions;
}
}
@@ -0,0 +1,61 @@
<?php
namespace Drupal\rest;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
/**
* Defines a configuration entity to store enabled REST resources.
*/
interface RestResourceConfigInterface extends ConfigEntityInterface, EntityWithPluginCollectionInterface {
/**
* Granularity value for per-method configuration.
*/
const METHOD_GRANULARITY = 'method';
/**
* Granularity value for per-resource configuration.
*/
const RESOURCE_GRANULARITY = 'resource';
/**
* Retrieves the REST resource plugin.
*
* @return \Drupal\rest\Plugin\ResourceInterface
* The resource plugin
*/
public function getResourcePlugin();
/**
* Retrieves a list of supported HTTP methods.
*
* @return string[]
* A list of supported HTTP methods.
*/
public function getMethods();
/**
* Retrieves a list of supported authentication providers.
*
* @param string $method
* The request method e.g GET or POST.
*
* @return string[]
* A list of supported authentication provider IDs.
*/
public function getAuthenticationProviders($method);
/**
* Retrieves a list of supported response formats.
*
* @param string $method
* The request method e.g GET or POST.
*
* @return string[]
* A list of supported format IDs.
*/
public function getFormats($method);
}
@@ -0,0 +1,51 @@
<?php
namespace Drupal\rest;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
use Drupal\rest\LinkManager\LinkManager;
use Drupal\rest\LinkManager\RelationLinkManager;
use Drupal\rest\LinkManager\TypeLinkManager;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Reference;
/**
* Provides BC services.
*
* These services are not added via rest.services.yml because the service
* classes extend classes from the HAL module. They also have no use without
* that module.
*/
class RestServiceProvider implements ServiceProviderInterface {
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
$modules = $container->getParameter(('container.modules'));
if (isset($modules['hal'])) {
// @deprecated in Drupal 8.3.x and will be removed before Drupal 9.0.0.
// Use hal.link_manager instead.
// @see https://www.drupal.org/node/2830467
$service_definition = new ChildDefinition(new Reference('hal.link_manager'));
$service_definition->setClass(LinkManager::class);
$container->setDefinition('rest.link_manager', $service_definition);
// @deprecated in Drupal 8.3.x and will be removed before Drupal 9.0.0.
// Use hal.link_manager.type instead.
// @see https://www.drupal.org/node/2830467
$service_definition = new ChildDefinition(new Reference('hal.link_manager.type'));
$service_definition->setClass(TypeLinkManager::class);
$container->setDefinition('rest.link_manager.type', $service_definition);
// @deprecated in Drupal 8.3.x and will be removed before Drupal 9.0.0.
// Use hal.link_manager.relation instead.
// @see https://www.drupal.org/node/2830467
$service_definition = new ChildDefinition(new Reference('hal.link_manager.relation'));
$service_definition->setClass(RelationLinkManager::class);
$container->setDefinition('rest.link_manager.relation', $service_definition);
}
}
}
@@ -0,0 +1,80 @@
<?php
namespace Drupal\rest\RouteProcessor;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Symfony\Component\Routing\Route;
/**
* Processes the BC REST routes, to ensure old route names continue to work.
*/
class RestResourceGetRouteProcessorBC implements OutboundRouteProcessorInterface {
/**
* The available serialization formats.
*
* @var string[]
*/
protected $serializerFormats = [];
/**
* The route provider.
*
* @var \Drupal\Core\Routing\RouteProviderInterface
*/
protected $routeProvider;
/**
* Constructs a RestResourceGetRouteProcessorBC object.
*
* @param string[] $serializer_formats
* The available serialization formats.
* @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
* The route provider.
*/
public function __construct(array $serializer_formats, RouteProviderInterface $route_provider) {
$this->serializerFormats = $serializer_formats;
$this->routeProvider = $route_provider;
}
/**
* {@inheritdoc}
*/
public function processOutbound($route_name, Route $route, array &$parameters, BubbleableMetadata $bubbleable_metadata = NULL) {
$route_name_parts = explode('.', $route_name);
// BC: the REST module originally created per-format GET routes, instead
// of a single route. To minimize the surface of this BC layer, this uses
// route definitions that are as empty as possible, plus an outbound route
// processor.
// @see \Drupal\rest\Plugin\ResourceBase::routes()
if ($route_name_parts[0] === 'rest' && $route_name_parts[count($route_name_parts) - 2] === 'GET' && in_array($route_name_parts[count($route_name_parts) - 1], $this->serializerFormats, TRUE)) {
array_pop($route_name_parts);
$redirected_route_name = implode('.', $route_name_parts);
@trigger_error(sprintf("The '%s' route is deprecated since version 8.5.x and will be removed in 9.0.0. Use the '%s' route instead.", $route_name, $redirected_route_name), E_USER_DEPRECATED);
static::overwriteRoute($route, $this->routeProvider->getRouteByName($redirected_route_name));
}
}
/**
* Overwrites one route's metadata with the other's.
*
* @param \Symfony\Component\Routing\Route $target_route
* The route whose metadata to overwrite.
* @param \Symfony\Component\Routing\Route $source_route
* The route whose metadata to read from.
*
* @see \Symfony\Component\Routing\Route
*/
protected static function overwriteRoute(Route $target_route, Route $source_route) {
$target_route->setPath($source_route->getPath());
$target_route->setDefaults($source_route->getDefaults());
$target_route->setRequirements($source_route->getRequirements());
$target_route->setOptions($source_route->getOptions());
$target_route->setHost($source_route->getHost());
$target_route->setSchemes($source_route->getSchemes());
$target_route->setMethods($source_route->getMethods());
}
}
@@ -0,0 +1,157 @@
<?php
namespace Drupal\rest\Routing;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteBuildEvent;
use Drupal\Core\Routing\RoutingEvents;
use Drupal\rest\Plugin\Type\ResourcePluginManager;
use Drupal\rest\RestResourceConfigInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouteCollection;
/**
* Subscriber for REST-style routes.
*/
class ResourceRoutes implements EventSubscriberInterface {
/**
* The plugin manager for REST plugins.
*
* @var \Drupal\rest\Plugin\Type\ResourcePluginManager
*/
protected $manager;
/**
* The REST resource config storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $resourceConfigStorage;
/**
* 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\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
*/
public function __construct(ResourcePluginManager $manager, EntityTypeManagerInterface $entity_type_manager, LoggerInterface $logger) {
$this->manager = $manager;
$this->resourceConfigStorage = $entity_type_manager->getStorage('rest_resource_config');
$this->logger = $logger;
}
/**
* Alters existing routes for a specific collection.
*
* @param \Drupal\Core\Routing\RouteBuildEvent $event
* The route build event.
* @return array
*/
public function onDynamicRouteEvent(RouteBuildEvent $event) {
// Iterate over all enabled REST resource config entities.
/** @var \Drupal\rest\RestResourceConfigInterface[] $resource_configs */
$resource_configs = $this->resourceConfigStorage->loadMultiple();
foreach ($resource_configs as $resource_config) {
if ($resource_config->status()) {
$resource_routes = $this->getRoutesForResourceConfig($resource_config);
$event->getRouteCollection()->addCollection($resource_routes);
}
}
}
/**
* Provides all routes for a given REST resource config.
*
* This method determines where a resource is reachable, what path
* replacements are used, the required HTTP method for the operation etc.
*
* @param \Drupal\rest\RestResourceConfigInterface $rest_resource_config
* The rest resource config.
*
* @return \Symfony\Component\Routing\RouteCollection
* The route collection.
*/
protected function getRoutesForResourceConfig(RestResourceConfigInterface $rest_resource_config) {
$plugin = $rest_resource_config->getResourcePlugin();
$collection = new RouteCollection();
foreach ($plugin->routes() as $name => $route) {
/** @var \Symfony\Component\Routing\Route $route */
// @todo: Are multiple methods possible here?
$methods = $route->getMethods();
// Only expose routes
// - that have an explicit method and allow >=1 format for that method
// - that exist for BC
// @see \Drupal\rest\RouteProcessor\RestResourceGetRouteProcessorBC
if (($methods && ($method = $methods[0]) && $supported_formats = $rest_resource_config->getFormats($method)) || $route->hasOption('bc_route')) {
$route->setRequirement('_csrf_request_header_token', 'TRUE');
// Check that authentication providers are defined.
if (empty($rest_resource_config->getAuthenticationProviders($method))) {
$this->logger->error('At least one authentication provider must be defined for resource @id', ['@id' => $rest_resource_config->id()]);
continue;
}
// Check that formats are defined.
if (empty($rest_resource_config->getFormats($method))) {
$this->logger->error('At least one format must be defined for resource @id', ['@id' => $rest_resource_config->id()]);
continue;
}
// Remove BC routes for unsupported formats.
if ($route->getOption('bc_route') === TRUE) {
$format_requirement = $route->getRequirement('_format');
if ($format_requirement && !in_array($format_requirement, $rest_resource_config->getFormats($method))) {
continue;
}
}
// The configuration has been validated, so we update the route to:
// - set the allowed response body content types/formats for methods
// that may send response bodies (unless hardcoded by the plugin)
// - set the allowed request body content types/formats for methods that
// allow request bodies to be sent (unless hardcoded by the plugin)
// - set the allowed authentication providers
if (in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'PATCH'], TRUE) && !$route->hasRequirement('_format')) {
$route->addRequirements(['_format' => implode('|', $rest_resource_config->getFormats($method))]);
}
if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE) && !$route->hasRequirement('_content_type_format')) {
$route->addRequirements(['_content_type_format' => implode('|', $rest_resource_config->getFormats($method))]);
}
$route->setOption('_auth', $rest_resource_config->getAuthenticationProviders($method));
$route->setDefault('_rest_resource_config', $rest_resource_config->id());
$parameters = $route->getOption('parameters') ?: [];
$route->setOption('parameters', $parameters + [
'_rest_resource_config' => [
'type' => 'entity:' . $rest_resource_config->getEntityTypeId(),
],
]);
$collection->add("rest.$name", $route);
}
}
return $collection;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[RoutingEvents::DYNAMIC] = 'onDynamicRouteEvent';
return $events;
}
}
@@ -0,0 +1,632 @@
<?php
namespace Drupal\rest\Tests;
@trigger_error(__NAMESPACE__ . '\RESTTestBase is deprecated in Drupal 8.3.x-dev and will be removed before Drupal 9.0.0. Use \Drupal\Tests\rest\Functional\ResourceTestBase and \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase instead. Only retained for contributed module tests that may be using this base class.', E_USER_DEPRECATED);
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\Entity\ConfigEntityType;
use Drupal\node\NodeInterface;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\simpletest\WebTestBase;
use GuzzleHttp\Cookie\FileCookieJar;
use GuzzleHttp\Cookie\SetCookie;
/**
* Test helper class that provides a REST client method to send HTTP requests.
*
* @deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use
* \Drupal\Tests\rest\Functional\ResourceTestBase and
* \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase
* instead. Only retained for contributed module tests that may be using this
* base class.
*/
abstract class RESTTestBase extends WebTestBase {
/**
* The REST resource config storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $resourceConfigStorage;
/**
* 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;
/**
* The raw response body from http request operations.
*
* @var array
*/
protected $responseBody;
/**
* Modules to install.
*
* @var array
*/
public static $modules = ['rest', 'entity_test'];
/**
* The last response.
*
* @var \Psr\Http\Message\ResponseInterface
*/
protected $response;
protected function setUp() {
parent::setUp();
$this->defaultFormat = 'hal_json';
$this->defaultMimeType = 'application/hal+json';
$this->defaultAuth = ['cookie'];
$this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config');
// Create a test content type for node testing.
if (in_array('node', static::$modules)) {
$this->drupalCreateContentType(['name' => 'resttest', 'type' => 'resttest']);
}
$this->cookieFile = $this->publicFilesDirectory . '/cookie.jar';
}
/**
* Calculates cookies used by guzzle later.
*
* @return \GuzzleHttp\Cookie\CookieJarInterface
* The used CURL options in guzzle.
*/
protected function cookies() {
$cookies = [];
foreach ($this->cookies as $key => $cookie) {
$cookies[$key][] = $cookie['value'];
}
$request = \Drupal::request();
$cookies = NestedArray::mergeDeep($cookies, $this->extractCookiesFromRequest($request));
$cookie_jar = new FileCookieJar($this->cookieFile);
foreach ($cookies as $key => $cookie_values) {
foreach ($cookie_values as $cookie_value) {
// setcookie() sets the value of a cookie to be deleted, when its gonna
// be removed.
if ($cookie_value !== 'deleted') {
$cookie_jar->setCookie(new SetCookie(['Name' => $key, 'Value' => $cookie_value, 'Domain' => $request->getHost()]));
}
}
}
return $cookie_jar;
}
/**
* 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.
* @param bool $csrf_token
* If NULL, a CSRF token will be retrieved and used. If FALSE, omit the
* X-CSRF-Token request header (to simulate developer error). Otherwise, the
* passed in value will be used as the value for the X-CSRF-Token request
* header (to simulate developer error, by sending an invalid CSRF token).
*
* @return string
* The content returned from the request.
*/
protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL, $csrf_token = NULL) {
if (!isset($mime_type)) {
$mime_type = $this->defaultMimeType;
}
if (!in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'])) {
// GET the CSRF token first for writing requests.
$requested_token = $this->drupalGet('session/token');
}
$client = \Drupal::httpClient();
$url = $this->buildUrl($url);
$options = [
'http_errors' => FALSE,
'cookies' => $this->cookies(),
'curl' => [
CURLOPT_HEADERFUNCTION => [&$this, 'curlHeaderCallback'],
],
];
switch ($method) {
case 'GET':
$options += [
'headers' => [
'Accept' => $mime_type,
],
];
$response = $client->get($url, $options);
break;
case 'HEAD':
$response = $client->head($url, $options);
break;
case 'POST':
$options += [
'headers' => $csrf_token !== FALSE ? [
'Content-Type' => $mime_type,
'X-CSRF-Token' => ($csrf_token === NULL ? $requested_token : $csrf_token),
] : [
'Content-Type' => $mime_type,
],
'body' => $body,
];
$response = $client->post($url, $options);
break;
case 'PUT':
$options += [
'headers' => $csrf_token !== FALSE ? [
'Content-Type' => $mime_type,
'X-CSRF-Token' => ($csrf_token === NULL ? $requested_token : $csrf_token),
] : [
'Content-Type' => $mime_type,
],
'body' => $body,
];
$response = $client->put($url, $options);
break;
case 'PATCH':
$options += [
'headers' => $csrf_token !== FALSE ? [
'Content-Type' => $mime_type,
'X-CSRF-Token' => ($csrf_token === NULL ? $requested_token : $csrf_token),
] : [
'Content-Type' => $mime_type,
],
'body' => $body,
];
$response = $client->patch($url, $options);
break;
case 'DELETE':
$options += [
'headers' => $csrf_token !== FALSE ? [
'Content-Type' => $mime_type,
'X-CSRF-Token' => ($csrf_token === NULL ? $requested_token : $csrf_token),
] : [],
];
$response = $client->delete($url, $options);
break;
}
$this->response = $response;
$this->responseBody = (string) $response->getBody();
$this->setRawContent($this->responseBody);
// Ensure that any changes to variables in the other thread are picked up.
$this->refreshVariables();
$this->verbose($method . ' request to: ' . $url .
'<hr />Code: ' . $this->response->getStatusCode() .
(isset($options['headers']) ? '<hr />Request headers: ' . nl2br(print_r($options['headers'], TRUE)) : '') .
(isset($options['body']) ? '<hr />Request body: ' . nl2br(print_r($options['body'], TRUE)) : '') .
'<hr />Response headers: ' . nl2br(print_r($response->getHeaders(), TRUE)) .
'<hr />Response body: ' . $this->responseBody);
return $this->responseBody;
}
/**
* {@inheritdoc}
*/
protected function assertResponse($code, $message = '', $group = 'Browser') {
if (!isset($this->response)) {
return parent::assertResponse($code, $message, $group);
}
return $this->assertEqual($code, $this->response->getStatusCode(), $message ? $message : "HTTP response expected $code, actual {$this->response->getStatusCode()}", $group);
}
/**
* {@inheritdoc}
*/
protected function drupalGetHeaders($all_requests = FALSE) {
if (!isset($this->response)) {
return parent::drupalGetHeaders($all_requests);
}
$lowercased_keys = array_map('strtolower', array_keys($this->response->getHeaders()));
return array_map(function (array $header) {
return implode(', ', $header);
}, array_combine($lowercased_keys, array_values($this->response->getHeaders())));
}
/**
* {@inheritdoc}
*/
protected function drupalGetHeader($name, $all_requests = FALSE) {
if (!isset($this->response)) {
return parent::drupalGetHeader($name, $all_requests);
}
if ($header = $this->response->getHeader($name)) {
return implode(', ', $header);
}
}
/**
* 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 $this->container->get('entity_type.manager')
->getStorage($entity_type)
->create($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_id
* The ID of the type of entity that should be created.
*
* @return array
* An array of values keyed by property name.
*/
protected function entityValues($entity_type_id) {
switch ($entity_type_id) {
case 'entity_test':
return [
'name' => $this->randomMachineName(),
'user_id' => 1,
'field_test_text' => [
0 => [
'value' => $this->randomString(),
'format' => 'plain_text',
],
],
];
case 'config_test':
return [
'id' => $this->randomMachineName(),
'label' => 'Test label',
];
case 'node':
return ['title' => $this->randomString(), 'type' => 'resttest'];
case 'node_type':
return [
'type' => 'article',
'name' => $this->randomMachineName(),
];
case 'user':
return ['name' => $this->randomMachineName()];
case 'comment':
return [
'subject' => $this->randomMachineName(),
'entity_type' => 'node',
'comment_type' => 'comment',
'comment_body' => $this->randomString(),
'entity_id' => 'invalid',
'field_name' => 'comment',
];
case 'taxonomy_vocabulary':
return [
'vid' => 'tags',
'name' => $this->randomMachineName(),
];
case 'block':
// Block placements depend on themes, ensure Bartik is installed.
$this->container->get('theme_installer')->install(['bartik']);
return [
'id' => strtolower($this->randomMachineName(8)),
'plugin' => 'system_powered_by_block',
'theme' => 'bartik',
'region' => 'header',
];
default:
if ($this->isConfigEntity($entity_type_id)) {
return $this->configEntityValues($entity_type_id);
}
return [];
}
}
/**
* 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|array $format
* (Optional) The serialization format, e.g. hal_json, or a list of formats.
* @param array $auth
* (Optional) The list of valid authentication methods.
*/
protected function enableService($resource_type, $method = 'GET', $format = NULL, array $auth = []) {
if ($resource_type) {
// Enable REST API for this entity type.
$resource_config_id = str_replace(':', '.', $resource_type);
// get entity by id
/** @var \Drupal\rest\RestResourceConfigInterface $resource_config */
$resource_config = $this->resourceConfigStorage->load($resource_config_id);
if (!$resource_config) {
$resource_config = $this->resourceConfigStorage->create([
'id' => $resource_config_id,
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [],
]);
}
$configuration = $resource_config->get('configuration');
if (is_array($format)) {
for ($i = 0; $i < count($format); $i++) {
$configuration[$method]['supported_formats'][] = $format[$i];
}
}
else {
if ($format == NULL) {
$format = $this->defaultFormat;
}
$configuration[$method]['supported_formats'][] = $format;
}
if (!is_array($auth) || empty($auth)) {
$auth = $this->defaultAuth;
}
foreach ($auth as $auth_provider) {
$configuration[$method]['supported_auth'][] = $auth_provider;
}
$resource_config->set('configuration', $configuration);
$resource_config->save();
}
else {
foreach ($this->resourceConfigStorage->loadMultiple() as $resource_config) {
$resource_config->delete();
}
}
$this->rebuildCache();
}
/**
* Rebuilds routing caches.
*/
protected function rebuildCache() {
$this->container->get('router.builder')->rebuildIfNeeded();
}
/**
* {@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) {
unset($this->response);
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_id
* 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_id, $operation) {
switch ($entity_type_id) {
case 'entity_test':
switch ($operation) {
case 'view':
return ['view test entity'];
case 'create':
case 'update':
case 'delete':
return ['administer entity_test content'];
}
case 'node':
switch ($operation) {
case 'view':
return ['access content'];
case 'create':
return ['create resttest content'];
case 'update':
return ['edit any resttest content'];
case 'delete':
return ['delete any resttest content'];
}
case 'comment':
switch ($operation) {
case 'view':
return ['access comments'];
case 'create':
return ['post comments', 'skip comment approval'];
case 'update':
return ['edit own comments'];
case 'delete':
return ['administer comments'];
}
break;
case 'user':
switch ($operation) {
case 'view':
return ['access user profiles'];
default:
return ['administer users'];
}
default:
if ($this->isConfigEntity($entity_type_id)) {
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
if ($admin_permission = $entity_type->getAdminPermission()) {
return [$admin_permission];
}
}
}
return [];
}
/**
* 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 $this->container->get('entity_type.manager')
->getStorage($this->testEntityType)->load($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('revision_log', NULL);
$node->set('uid', NULL);
return $node;
}
/**
* Check to see if the HTTP request response body is identical to the expected
* value.
*
* @param $expected
* The first value to check.
* @param $message
* (optional) A message to display with the assertion. Do not translate
* messages: use \Drupal\Component\Render\FormattableMarkup to embed
* variables in the message text, not t(). If left blank, a default message
* will be displayed.
* @param $group
* (optional) The group this message is in, which is displayed in a column
* in test output. Use 'Debug' to indicate this is debugging output. Do not
* translate this string. Defaults to 'Other'; most tests do not override
* this default.
*
* @return bool
* TRUE if the assertion succeeded, FALSE otherwise.
*/
protected function assertResponseBody($expected, $message = '', $group = 'REST Response') {
return $this->assertIdentical($expected, $this->responseBody, $message ? $message : strtr('Response body @expected (expected) is equal to @response (actual).', ['@expected' => var_export($expected, TRUE), '@response' => var_export($this->responseBody, TRUE)]), $group);
}
/**
* Checks if an entity type id is for a Config Entity.
*
* @param string $entity_type_id
* The entity type ID to check.
*
* @return bool
* TRUE if the entity is a Config Entity, FALSE otherwise.
*/
protected function isConfigEntity($entity_type_id) {
return \Drupal::entityTypeManager()->getDefinition($entity_type_id) instanceof ConfigEntityType;
}
/**
* Provides an array of suitable property values for a config entity type.
*
* Config entities have some common keys that need to be created. Required
* properties differ among config entity types, so we keep a minimum mapping
* here.
*
* @param string $entity_type_id
* The ID of the type of entity that should be created.
*
* @return array
* An array of values keyed by property name.
*/
protected function configEntityValues($entity_type_id) {
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
$keys = $entity_type->getKeys();
$values = [];
// Fill out known key values that are shared across entity types.
foreach ($keys as $key) {
if ($key === 'id' || $key === 'label') {
$values[$key] = $this->randomMachineName();
}
}
// Add extra values for particular entity types.
switch ($entity_type_id) {
case 'block':
$values['plugin'] = 'system_powered_by_block';
break;
}
return $values;
}
}