first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

33
core/modules/rest/rest.api.php Executable file
View File

@@ -0,0 +1,33 @@
<?php
/**
* @file
* Describes hooks provided by the RESTful Web Services module.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Alter the resource plugin definitions.
*
* @param array $definitions
* The collection of resource definitions.
*/
function hook_rest_resource_alter(&$definitions) {
if (isset($definitions['entity:node'])) {
// We want to handle REST requests regarding nodes with our own plugin
// class.
$definitions['entity:node']['class'] = 'Drupal\my_module\Plugin\rest\resource\NodeResource';
// Serialized nodes should be expanded to my specific node class.
$definitions['entity:node']['serialization_class'] = 'Drupal\my_module\Entity\MyNode';
}
// We don't want Views to show up in the array of plugins at all.
unset($definitions['entity:view']);
}
/**
* @} End of "addtogroup hooks".
*/

12
core/modules/rest/rest.info.yml Executable file
View File

@@ -0,0 +1,12 @@
name: 'RESTful Web Services'
type: module
description: 'Provides a framework for exposing REST resources.'
package: Web services
# version: VERSION
dependencies:
- drupal:serialization
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

13
core/modules/rest/rest.install Executable file
View File

@@ -0,0 +1,13 @@
<?php
/**
* @file
* Install, update and uninstall functions for the rest module.
*/
/**
* Implements hook_update_last_removed().
*/
function rest_update_last_removed() {
return 8401;
}

36
core/modules/rest/rest.module Executable file
View File

@@ -0,0 +1,36 @@
<?php
/**
* @file
* RESTful web services module.
*/
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function rest_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.rest':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The RESTful Web Services module provides a framework for exposing REST resources on your site. It provides support for content entity types such as the main site content, comments, content blocks, taxonomy terms, and user accounts, etc. (see the <a href=":field">Field module help page</a> for more information about entities). REST support for content items of the Node module is installed by default, and support for other types of content entities can be enabled. Other modules may add support for other types of REST resources. For more information, see the <a href=":rest">online documentation for the RESTful Web Services module</a>.', [':rest' => 'https://www.drupal.org/documentation/modules/rest', ':field' => (\Drupal::moduleHandler()->moduleExists('field')) ? Url::fromRoute('help.page', ['name' => 'field'])->toString() : '#']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Installing supporting modules') . '</dt>';
$output .= '<dd>' . t('In order to use REST on a website, you need to install modules that provide serialization and authentication services. You can use the Core module <a href=":serialization">serialization</a> for serialization and <a href=":basic_auth">HTTP Basic Authentication</a> for authentication, or install a contributed or custom module.', [':serialization' => (\Drupal::moduleHandler()->moduleExists('serialization')) ? Url::fromRoute('help.page', ['name' => 'serialization'])->toString() : 'https://www.drupal.org/docs/8/core/modules/serialization/overview', ':basic_auth' => (\Drupal::moduleHandler()->moduleExists('basic_auth')) ? Url::fromRoute('help.page', ['name' => 'basic_auth'])->toString() : 'https://www.drupal.org/docs/8/core/modules/basic_auth/overview']) . '</dd>';
$output .= '<dt>' . t('Enabling REST support for an entity type') . '</dt>';
$output .= '<dd>' . t('REST support for content types (provided by the <a href=":node">Node</a> module) is enabled by default. To enable support for other content entity types, you can use a <a href=":config" target="blank">process based on configuration editing</a> or the contributed <a href=":restui">REST UI module</a>.', [':node' => (\Drupal::moduleHandler()->moduleExists('node')) ? Url::fromRoute('help.page', ['name' => 'node'])->toString() : '#', ':config' => 'https://www.drupal.org/documentation/modules/rest', ':restui' => 'https://www.drupal.org/project/restui']) . '</dd>';
$output .= '<dd>' . t('You will also need to grant anonymous users permission to perform each of the REST operations you want to be available, and set up authentication properly to authorize web requests.') . '</dd>';
$output .= '<dt>' . t('General') . '</dt>';
$output .= '<dd>' . t('The <a href=":rest-docs">RESTful Web Services</a> and <a href=":jsonapi-docs">JSON:API</a> modules serve similar purposes. <a href=":comparison">Read the comparison of the RESTFul Web Services and JSON:API modules</a> to determine the best choice for your site.', [
':rest-docs' => 'https://www.drupal.org/docs/8/core/modules/rest',
':jsonapi-docs' => 'https://www.drupal.org/docs/8/modules/json-api',
':comparison' => 'https://www.drupal.org/docs/8/modules/jsonapi/jsonapi-vs-cores-rest-module',
]) . '</dd>';
$output .= '</dl>';
return $output;
}
}

View File

@@ -0,0 +1,5 @@
permission_callbacks:
- Drupal\rest\RestPermissions::permissions
administer rest resources:
title: 'Administer REST resource configuration'

View File

@@ -0,0 +1,18 @@
<?php
/**
* @file
* Post update functions for Rest.
*/
/**
* Implements hook_removed_post_updates().
*/
function rest_removed_post_updates() {
return [
'rest_post_update_create_rest_resource_config_entities' => '9.0.0',
'rest_post_update_resource_granularity' => '9.0.0',
'rest_post_update_161923' => '9.0.0',
'rest_post_update_delete_settings' => '10.0.0',
];
}

View File

@@ -0,0 +1,26 @@
services:
_defaults:
autoconfigure: true
plugin.manager.rest:
class: Drupal\rest\Plugin\Type\ResourcePluginManager
arguments: ['@container.namespaces', '@cache.discovery', '@module_handler']
cache.rest:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: ['@cache_factory', 'get']
arguments: [rest]
rest.resource_routes:
class: Drupal\rest\Routing\ResourceRoutes
arguments: ['@plugin.manager.rest', '@entity_type.manager', '@logger.channel.rest']
logger.channel.rest:
parent: logger.channel_base
arguments: ['rest']
# Event subscribers.
rest.resource_response.subscriber:
class: Drupal\rest\EventSubscriber\ResourceResponseSubscriber
arguments: ['@serializer', '@renderer', '@current_route_match']
rest.resource.entity.post_route.subscriber:
class: \Drupal\rest\EventSubscriber\EntityResourcePostRouteSubscriber
arguments: ['@entity_type.manager']

View File

@@ -0,0 +1,64 @@
<?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.
*
* This property is optional and it does not need to be declared.
*
* @see \Symfony\Component\Serializer\SerializerInterface's "type" parameter.
*
* @var string
*/
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 = [];
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\rest\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a REST resource attribute 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
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class RestResource extends Plugin {
/**
* Constructs a RestResource attribute.
*
* @param string $id
* The REST resource plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $label
* The human-readable name of the REST resource plugin.
* @param string|null $serialization_class
* (optional) The serialization class to deserialize serialized data into.
* @param class-string|null $deriver
* (optional) The deriver class for the rest resource.
* @param array $uri_paths
* (optional) The URI paths that this REST resource plugin provides.
* - key: The link relation type plugin ID.
* - value: The URL template.
*
* @see \Symfony\Component\Serializer\SerializerInterface
* @see core/core.link_relation_types.yml
*/
public function __construct(
public readonly string $id,
public readonly TranslatableMarkup $label,
public readonly ?string $serialization_class = NULL,
public readonly ?string $deriver = NULL,
public readonly array $uri_paths = [],
) {}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,278 @@
<?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",
* 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);
}
}
/**
* 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();
}
}

View File

@@ -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 (!str_starts_with($plugin_id, '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(): array {
// Priority -10, to run after \Drupal\rest\Routing\ResourceRoutes, which has
// priority 0.
$events[RoutingEvents::DYNAMIC][] = ['onDynamicRouteEvent', -10];
return $events;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace Drupal\rest\EventSubscriber;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Cache\CacheableResponseInterface;
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\ResponseEvent;
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\ResponseEvent $event
* The event to process.
*/
public function onResponse(ResponseEvent $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->getContentTypeFormat();
// 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.
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.
*
* @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.
*
* @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(),
];
$output = $serializer->serialize($data, $format, $serialization_context);
if ($response instanceof CacheableResponseInterface) {
$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(): array {
// 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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Drupal\rest\Plugin\Deriver;
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 {
/**
* 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;
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Drupal\rest\Plugin;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Common base class for resource plugins.
*
* 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 = $definition['uri_paths']['canonical'] ?? '/' . strtr($this->pluginId, ':', '/') . '/{id}';
$create_path = $definition['uri_paths']['create'] ?? '/' . strtr($this->pluginId, ':', '/');
$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);
}
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;
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Drupal\rest\Plugin\Type;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\rest\Attribute\RestResource;
use Drupal\rest\Plugin\ResourceInterface;
/**
* 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,
ResourceInterface::class,
RestResource::class,
'Drupal\rest\Annotation\RestResource',
);
$this->setCacheBackend($cache_backend, 'rest_plugins');
$this->alterInfo('rest_resource');
}
}

View File

@@ -0,0 +1,467 @@
<?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\Core\StringTranslation\TranslatableMarkup;
use Drupal\rest\Attribute\RestResource;
use Drupal\rest\Plugin\Deriver\EntityDeriver;
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: new TranslatableMarkup("Entity"),
serialization_class: "Drupal\Core\Entity\Entity",
deriver: EntityDeriver::class,
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() {
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);
}
}
}
}

View File

@@ -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 $field_name) {
if (!$entity->get($field_name)->access('edit')) {
throw new AccessDeniedHttpException("Access denied on creating field '$field_name'.");
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,488 @@
<?php
namespace Drupal\rest\Plugin\views\display;
use Drupal\Component\Utility\Unicode;
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\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Attribute\ViewsDisplay;
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: new TranslatableMarkup("REST export"),
help: new TranslatableMarkup("Create a REST export resource."),
admin: new TranslatableMarkup("REST export"),
uses_route: TRUE,
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 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);
$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' => Unicode::truncate($auth, 24, FALSE, TRUE),
];
// 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_has_format = $route->hasRequirement('_format');
$route_formats = $route_has_format ? explode('|', $route->getRequirement('_format')) : [];
$view_route_formats = $view_route->hasRequirement('_format') ? explode('|', $view_route->getRequirement('_format')) : [];
return $this->overrideAppliesPathAndMethod($view_path, $view_route, $route)
&& (!$route_has_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();
}
/**
* 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);
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace Drupal\rest\Plugin\views\row;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Attribute\ViewsRow;
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: new TranslatableMarkup("Entity"),
help: new TranslatableMarkup("Use entities as row data."),
display_types: ["data"]
)]
class DataEntityRow extends RowPluginBase {
use EntityTranslationRenderTrait;
/**
* {@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 type 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) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->languageManager = $language_manager;
$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->getEntityTranslationByRelationship($row->_entity, $row);
}
/**
* {@inheritdoc}
*/
public function getEntityTypeId() {
return $this->view->getBaseEntityType()->id();
}
/**
* {@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());
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace Drupal\rest\Plugin\views\row;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Attribute\ViewsRow;
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: new TranslatableMarkup("Fields"),
help: new TranslatableMarkup("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' => $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' => $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 $item[$key] ?? NULL;
}, $options);
}
}

View File

@@ -0,0 +1,206 @@
<?php
namespace Drupal\rest\Plugin\views\style;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Attribute\ViewsStyle;
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: new TranslatableMarkup("Serializer"),
help: new TranslatableMarkup("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);
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace Drupal\rest;
use Drupal\Component\Utility\ArgumentsResolver;
use Drupal\Core\Cache\CacheableResponseInterface;
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 serializer.
*
* @var \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface
*/
protected $serializer;
/**
* Creates a new RequestHandler instance.
*
* @param \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface $serializer
* The serializer.
*/
public function __construct(SerializerInterface $serializer) {
$this->serializer = $serializer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$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);
}
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->getContentTypeFormat();
$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);
$arguments = $argument_resolver->getArguments([$resource, $method]);
// 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 type hint, 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);
}
}

View File

@@ -0,0 +1,41 @@
<?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.
*
* Routes that return this response must specify the '_format' requirement.
*
* @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);
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Drupal\rest;
use Drupal\Component\Utility\NestedArray;
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();
// Add the rest resource configuration entity as a dependency to the
// permissions.
$permissions += array_map(function (array $permission_info) use ($resource_config) {
$merge_info['dependencies'][$resource_config->getConfigDependencyKey()] = [
$resource_config->getConfigDependencyName(),
];
return NestedArray::mergeDeep($permission_info, $merge_info);
}, $plugin->permissions());
}
return $permissions;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,146 @@
<?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.
*/
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.
if (($methods && ($method = $methods[0]) && $rest_resource_config->getFormats($method))) {
$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;
}
// 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', 'DELETE'], 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(): array {
$events[RoutingEvents::DYNAMIC] = 'onDynamicRouteEvent';
return $events;
}
}

View File

@@ -0,0 +1,11 @@
name: 'Configuration test REST'
type: module
package: Testing
# version: VERSION
dependencies:
- drupal:config_test
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,35 @@
<?php
/**
* @file
* Contains hook implementations for testing REST module.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_entity_type_alter().
*/
function config_test_rest_entity_type_alter(array &$entity_types) {
// Undo part of what config_test_entity_type_alter() did: remove this
// config_test_no_status entity type, because it uses the same entity class as
// the config_test entity type, which makes REST deserialization impossible.
unset($entity_types['config_test_no_status']);
}
/**
* Implements hook_ENTITY_TYPE_access().
*/
function config_test_rest_config_test_access(EntityInterface $entity, $operation, AccountInterface $account) {
// Add permission, so that EntityResourceTestBase's scenarios can test access
// being denied. By default, all access is always allowed for the config_test
// config entity.
$access_result = AccessResult::forbiddenIf(!$account->hasPermission('view config_test'))->cachePerPermissions();
if (!$access_result->isAllowed() && $access_result instanceof AccessResultReasonInterface) {
$access_result->setReason("The 'view config_test' permission is required.");
}
return $access_result;
}

View File

@@ -0,0 +1,2 @@
view config_test:
title: 'View ConfigTest entities'

View File

@@ -0,0 +1,10 @@
name: 'REST test'
type: module
description: 'Provides test hooks and resources for REST module.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,72 @@
<?php
/**
* @file
* Contains hook implementations for testing REST module.
*/
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Access\AccessResult;
/**
* Implements hook_entity_field_access().
*
* @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::setUp()
*/
function rest_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPost()
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
if ($field_definition->getName() === 'field_rest_test') {
switch ($operation) {
case 'view':
// Never ever allow this field to be viewed: this lets
// EntityResourceTestBase::testGet() test in a "vanilla" way.
return AccessResult::forbidden();
case 'edit':
return AccessResult::forbidden();
}
}
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testGet()
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
if ($field_definition->getName() === 'field_rest_test_multivalue') {
switch ($operation) {
case 'view':
// Never ever allow this field to be viewed: this lets
// EntityResourceTestBase::testGet() test in a "vanilla" way.
return AccessResult::forbidden();
}
}
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testGet()
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
if ($field_definition->getName() === 'rest_test_validation') {
switch ($operation) {
case 'view':
// Never ever allow this field to be viewed: this lets
// EntityResourceTestBase::testGet() test in a "vanilla" way.
return AccessResult::forbidden();
}
}
// No opinion.
return AccessResult::neutral();
}
/**
* Implements hook_entity_base_field_info().
*/
function rest_test_entity_base_field_info(EntityTypeInterface $entity_type) {
$fields = [];
$fields['rest_test_validation'] = BaseFieldDefinition::create('string')
->setLabel(t('REST test validation field'))
->setDescription(t('A text field with some special validations attached used for testing purposes'))
->addConstraint('rest_test_validation');
return $fields;
}

View File

@@ -0,0 +1,18 @@
services:
rest_test.authentication.test_auth:
class: Drupal\rest_test\Authentication\Provider\TestAuth
tags:
- { name: authentication_provider, provider_id: 'rest_test_auth' }
rest_test.authentication.test_auth_global:
class: Drupal\rest_test\Authentication\Provider\TestAuthGlobal
tags:
- { name: authentication_provider, provider_id: 'rest_test_auth_global', global: TRUE }
rest_test.page_cache_request_policy.deny_test_auth_requests:
class: Drupal\rest_test\PageCache\RequestPolicy\DenyTestAuthRequests
public: false
tags:
- { name: page_cache_request_policy }
rest_test.encoder.foobar:
class: Drupal\serialization\Encoder\JsonEncoder
tags:
- { name: encoder, format: foobar }

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\rest_test\Authentication\Provider;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Authentication provider for testing purposes.
*/
class TestAuth implements AuthenticationProviderInterface {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return $request->headers->has('REST-test-auth');
}
/**
* {@inheritdoc}
*/
public function authenticate(Request $request) {
return NULL;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\rest_test\Authentication\Provider;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Global authentication provider for testing purposes.
*/
class TestAuthGlobal implements AuthenticationProviderInterface {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return $request->headers->has('REST-test-auth-global');
}
/**
* {@inheritdoc}
*/
public function authenticate(Request $request) {
return NULL;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\rest_test\PageCache\RequestPolicy;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Cache policy for pages requested with REST Test Auth.
*
* This policy disallows caching of requests that use the REST Test Auth
* authentication provider for security reasons (just like basic_auth).
* Otherwise responses for authenticated requests can get into the page cache
* and could be delivered to unprivileged users.
*
* @see \Drupal\rest_test\Authentication\Provider\TestAuth
* @see \Drupal\rest_test\Authentication\Provider\TestAuthGlobal
* @see \Drupal\basic_auth\PageCache\DisallowBasicAuthRequests
*/
class DenyTestAuthRequests implements RequestPolicyInterface {
/**
* {@inheritdoc}
*/
public function check(Request $request) {
if ($request->headers->has('REST-test-auth') || $request->headers->has('REST-test-auth-global')) {
return self::DENY;
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\rest_test\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Adds some validations for a REST test field.
*
* @see \Drupal\Core\TypedData\OptionsProviderInterface
*/
#[Constraint(
id: 'rest_test_validation',
label: new TranslatableMarkup('REST test validation', [], ['context' => 'Validation'])
)]
class RestTestConstraint extends SymfonyConstraint {
public $message = 'REST test validation failed';
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\rest_test\Plugin\Validation\Constraint;
use Drupal\Core\Field\FieldItemListInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validator for \Drupal\rest_test\Plugin\Validation\Constraint\RestTestConstraint.
*/
class RestTestConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
if ($value instanceof FieldItemListInterface) {
$value = $value->getValue();
if (!empty($value[0]['value']) && $value[0]['value'] === 'ALWAYS_FAIL') {
$this->context->addViolation($constraint->message);
}
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Drupal\rest_test\Plugin\rest\resource;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\rest\Attribute\RestResource;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
/**
* Class used to test that serialization_class is optional.
*/
#[RestResource(
id: "serialization_test",
label: new TranslatableMarkup("Optional serialization_class"),
serialization_class: "",
uri_paths: []
)]
class NoSerializationClassTestResource extends ResourceBase {
/**
* Responds to a POST request.
*
* @param array $data
* An array with the payload.
*
* @return \Drupal\rest\ResourceResponse
*/
public function post(array $data) {
return new ResourceResponse($data);
}
}

View File

@@ -0,0 +1,13 @@
name: 'REST test views'
type: module
description: 'Provides default views for views REST tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:rest
- drupal:views
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,21 @@
<?php
/**
* @file
* Test hook implementations for the REST views test module.
*/
use Drupal\views\ViewExecutable;
/**
* Implements hook_views_post_execute().
*/
function rest_test_views_views_post_execute(ViewExecutable $view) {
// Attach a custom header to the test_data_export view.
if ($view->id() === 'test_serializer_display_entity') {
if ($value = \Drupal::state()->get('rest_test_views_set_header', FALSE)) {
$view->getResponse()->headers->set('Custom-Header', $value);
}
}
}

View File

@@ -0,0 +1,276 @@
langcode: en
status: true
dependencies:
config:
- node.type.article
module:
- node
- rest
- user
id: test_excluded_field_token_display
label: 'Test Excluded Field Token Display'
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: serializer
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: true
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: false
ellipsis: false
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: false
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: title
plugin_id: field
nothing:
id: nothing
table: views
field: nothing
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: true
text: '{{ title }}'
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: false
plugin_id: custom
filters:
status:
value: '1'
table: node_field_data
field: status
plugin_id: boolean
entity_type: node
entity_field: status
id: status
expose:
operator: ''
group: 1
type:
id: type
table: node_field_data
field: type
value:
article: article
entity_type: node
entity_field: type
plugin_id: bundle
sorts:
nid:
id: nid
table: node_field_data
field: nid
order: DESC
entity_type: node
entity_field: nid
plugin_id: standard
relationship: none
group_type: group
admin_label: ''
exposed: false
expose:
label: ''
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- request_format
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: 'REST export'
position: 1
display_options:
display_extenders: { }
path: rest/test/excluded-field-token
pager:
type: some
options:
items_per_page: 10
offset: 0
style:
type: serializer
options:
formats:
json: json
row:
type: data_field
options:
field_options:
title:
alias: ''
raw_output: false
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- request_format
- 'user.node_grants:view'
- user.permissions
tags: { }

View File

@@ -0,0 +1,210 @@
langcode: en
status: true
dependencies:
config:
- node.type.article
module:
- node
- rest
- serialization
- user
id: test_field_counter_display
label: 'Test Field Counter Display'
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: serializer
options:
formats:
json: json
row:
type: data_field
options:
field_options:
title:
alias: ''
raw_output: false
fields:
counter:
id: counter
table: views
field: counter
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
counter_start: 1
plugin_id: counter
filters:
status:
value: '1'
table: node_field_data
field: status
plugin_id: boolean
entity_type: node
entity_field: status
id: status
expose:
operator: ''
operator_limit_selection: false
operator_list: { }
group: 1
type:
id: type
table: node_field_data
field: type
value:
article: article
entity_type: node
entity_field: type
plugin_id: bundle
expose:
operator_limit_selection: false
operator_list: { }
sorts:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
order: DESC
exposed: false
expose:
label: ''
entity_type: node
entity_field: nid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_interface'
- request_format
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: 'REST export'
position: 1
display_options:
display_extenders: { }
path: rest/test/field-counter
pager:
type: some
options:
items_per_page: 10
offset: 0
defaults:
style: true
row: true
cache_metadata:
max-age: -1
contexts:
- 'languages:language_interface'
- request_format
- 'user.node_grants:view'
- user.permissions
tags: { }

View File

@@ -0,0 +1,54 @@
langcode: en
status: true
dependencies:
module:
- rest
- user
id: test_serializer_display_entity
label: 'Test serialize display entity rows'
module: rest
description: ''
tag: ''
base_table: entity_test
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_entity
sorts:
id:
id: standard
table: entity_test
field: id
order: DESC
plugin_id: date
entity_type: entity_test
entity_field: id
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
path: test/serialize/entity

View File

@@ -0,0 +1,46 @@
langcode: en
status: true
dependencies:
module:
- entity_test
- rest
id: test_serializer_display_entity_translated
label: 'Test serialize translated entity rows'
module: rest
description: ''
tag: ''
base_table: entity_test_mul_property_data
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_entity
title: 'Test serialize translated entity rows'
rendering_language: '***LANGUAGE_entity_translation***'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
path: test/serialize/translated_entity

View File

@@ -0,0 +1,109 @@
langcode: en
status: true
dependencies:
module:
- rest
- user
id: test_serializer_display_field
label: 'Test serializer display field rows'
module: rest
description: ''
tag: ''
base_table: views_test_data
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_field
fields:
name:
id: name
table: views_test_data
field: name
label: ''
plugin_id: string
nothing:
id: nothing
table: views
field: nothing
relationship: none
group_type: group
admin_label: ''
label: 'Custom text'
exclude: false
alter:
alter_text: true
text: TEST
plugin_id: custom
created:
id: created
table: views_test_data
field: created
type: timestamp
settings:
date_format: medium
custom_date_format: ''
timezone: ''
plugin_id: field
sorts:
created:
id: created
table: views_test_data
field: created
order: DESC
plugin_id: date
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/field
access:
type: none
style:
type: serializer
row:
type: data_field
rest_export_2:
display_plugin: rest_export
id: rest_export_2
display_title: 'serialize - access denied'
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/denied
access:
type: perm
options:
perm: 'administer views'
style:
type: serializer
row:
type: data_field

View File

@@ -0,0 +1,171 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
module:
- field
- node
- rest
- rest_test_views
- user
id: test_serializer_node_display_field
label: 'Test serializer display field rows for entity fields'
module: rest_test_views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'administer views'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_field
fields:
nid:
id: nid
table: node_field_data
field: nid
plugin_id: field
entity_type: node
entity_field: nid
title:
id: title
table: node_field_data
field: title
label: Title
exclude: false
alter:
alter_text: false
element_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
entity_type: node
entity_field: title
type: string
settings:
link_to_entity: true
plugin_id: field
body:
id: body
table: node__body
field: body
relationship: none
group_type: group
admin_label: ''
label: Body
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: text_default
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
plugin_id: field
entity_type: node
entity_field: body
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/node-field
access:
type: none
style:
type: serializer
row:
type: data_field
rest_export_2:
display_plugin: rest_export
id: rest_export_2
display_title: 'REST export 2'
position: 2
display_options:
display_extenders: { }
auth:
basic_auth: basic_auth
path: test/serialize/auth_with_perm
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- request_format
- 'user.node_grants:view'
- user.permissions
tags:
- 'config:field.storage.node.body'

View File

@@ -0,0 +1,171 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
module:
- field
- node
- rest
- rest_test_views
- user
id: test_serializer_node_exposed_filter
label: 'Test serializer display for exposed filters'
module: rest_test_views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_field
fields:
nid:
id: nid
table: node_field_data
field: nid
plugin_id: field
entity_type: node
entity_field: nid
body:
id: body
table: node__body
field: body
relationship: none
group_type: group
admin_label: ''
label: Body
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: text_default
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
plugin_id: field
entity_type: node
entity_field: body
filters:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
operator: starts
value: ''
group: 1
exposed: true
expose:
operator_id: title_op
label: Title
description: ''
use_operator: false
operator: title_op
identifier: title
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
entity_field: title
plugin_id: string
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/node-exposed-filter
access:
type: none
style:
type: serializer
row:
type: data_field

View File

@@ -0,0 +1,69 @@
langcode: en
status: true
dependencies:
module:
- rest
- user
id: test_serializer_shared_path
label: 'Test serializer shared path'
module: rest
description: ''
tag: ''
base_table: entity_test
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_entity
sorts:
id:
id: standard
table: entity_test
field: id
order: DESC
plugin_id: date
entity_type: entity_test
entity_field: id
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
path: test/serialize/shared
page_1:
display_plugin: page
id: page_1
display_title: page
position: null
display_options:
defaults:
access: false
style: false
row: false
style:
type: default
row:
type: entity:entity_test
path: test/serialize/shared

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Url;
use Psr\Http\Message\ResponseInterface;
/**
* Defines a trait for testing with no authentication provider.
*
* This is intended to be used with
* \Drupal\Tests\rest\Functional\ResourceTestBase.
*
* Characteristics:
* - When no authentication provider is being used, there also cannot be any
* particular error response for missing authentication, since by definition
* there is not any authentication.
* - For the same reason, there are no authentication edge cases to test.
* - Because no authentication is required, this is vulnerable to CSRF attacks
* by design. Hence a REST resource should probably only allow for anonymous
* for safe (GET/HEAD) HTTP methods, and only with extreme care should unsafe
* (POST/PATCH/DELETE) HTTP methods be allowed for a REST resource that allows
* anonymous access.
*/
trait AnonResourceTestTrait {
/**
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
throw new \LogicException('When testing for anonymous users, authentication cannot be missing.');
}
/**
* {@inheritdoc}
*/
protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Url;
use Psr\Http\Message\ResponseInterface;
/**
* Trait for ResourceTestBase subclasses testing $auth=basic_auth.
*
* Characteristics:
* - Every request must send an Authorization header.
* - When accessing a URI that requires authentication without being
* authenticated, a 401 response must be sent.
* - Because every request must send an authorization, there is no danger of
* CSRF attacks.
*/
trait BasicAuthResourceTestTrait {
/**
* {@inheritdoc}
*/
protected function getAuthenticationRequestOptions($method) {
return [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
],
];
}
/**
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
if ($method !== 'GET') {
return $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response);
}
$expected_page_cache_header_value = $method === 'GET' ? 'MISS' : FALSE;
$expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE))
// @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
->addCacheableDependency($this->config('system.site'))
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheTags(['config:user.role.anonymous']);
// Only add the 'user.roles:anonymous' cache context if its parent cache
// context is not already present.
if (!in_array('user.roles', $expected_cacheability->getCacheContexts(), TRUE)) {
$expected_cacheability->addCacheContexts(['user.roles:anonymous']);
}
$this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), $expected_page_cache_header_value, FALSE);
}
/**
* {@inheritdoc}
*/
protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Url;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
/**
* Trait for ResourceTestBase subclasses testing $auth=cookie.
*
* Characteristics:
* - After performing a valid "log in" request, the server responds with a 2xx
* status code and a 'Set-Cookie' response header. This cookie is what
* continues to identify the user in subsequent requests.
* - When accessing a URI that requires authentication without being
* authenticated, a standard 403 response must be sent.
* - Because of the reliance on cookies, and the fact that user agents send
* cookies with every request, this is vulnerable to CSRF attacks. To mitigate
* this, the response for the "log in" request contains a CSRF token that must
* be sent with every unsafe (POST/PATCH/DELETE) HTTP request.
*/
trait CookieResourceTestTrait {
/**
* The session cookie.
*
* @see ::initAuthentication
*
* @var string
*/
protected $sessionCookie;
/**
* The CSRF token.
*
* @see ::initAuthentication
*
* @var string
*/
protected $csrfToken;
/**
* The logout token.
*
* @see ::initAuthentication
*
* @var string
*/
protected $logoutToken;
/**
* {@inheritdoc}
*/
protected function initAuthentication() {
$user_login_url = Url::fromRoute('user.login.http')
->setRouteParameter('_format', static::$format);
$request_body = [
'name' => $this->account->name->value,
'pass' => $this->account->passRaw,
];
$request_options[RequestOptions::BODY] = $this->serializer->encode($request_body, static::$format);
$request_options[RequestOptions::HEADERS] = [
'Content-Type' => static::$mimeType,
];
$response = $this->request('POST', $user_login_url, $request_options);
// Parse and store the session cookie.
$this->sessionCookie = explode(';', $response->getHeader('Set-Cookie')[0], 2)[0];
// Parse and store the CSRF token and logout token.
$data = $this->serializer->decode((string) $response->getBody(), static::$format);
$this->csrfToken = $data['csrf_token'];
$this->logoutToken = $data['logout_token'];
}
/**
* {@inheritdoc}
*/
protected function getAuthenticationRequestOptions($method) {
$request_options[RequestOptions::HEADERS]['Cookie'] = $this->sessionCookie;
// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
if (!in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
$request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
}
return $request_options;
}
/**
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
// Requests needing cookie authentication but missing it results in a 403
// response. The cookie authentication mechanism sets no response message.
// Hence, effectively, this is just the 403 response that one gets as the
// anonymous user trying to access a certain REST resource.
// @see \Drupal\user\Authentication\Provider\Cookie
// @todo https://www.drupal.org/node/2847623
if ($method === 'GET') {
$expected_cookie_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE));
// - \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies
// to cacheable anonymous responses: it updates their cacheability.
// - A 403 response to a GET request is cacheable.
// Therefore we must update our cacheability expectations accordingly.
if (in_array('user.permissions', $expected_cookie_403_cacheability->getCacheContexts(), TRUE)) {
$expected_cookie_403_cacheability->addCacheTags(['config:user.role.anonymous']);
}
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_cookie_403_cacheability->getCacheTags(), $expected_cookie_403_cacheability->getCacheContexts(), 'MISS', FALSE);
}
else {
$this->assertResourceErrorResponse(403, FALSE, $response);
}
}
/**
* {@inheritdoc}
*/
protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
// X-CSRF-Token request header is unnecessary for safe and side effect-free
// HTTP methods. No need for additional assertions.
// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
if (in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
return;
}
unset($request_options[RequestOptions::HEADERS]['X-CSRF-Token']);
// DX: 403 when missing X-CSRF-Token request header.
$response = $this->request($method, $url, $request_options);
$this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is missing', $response);
$request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = 'this-is-not-the-token-you-are-looking-for';
// DX: 403 when invalid X-CSRF-Token request header.
$response = $this->request($method, $url, $request_options);
$this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is invalid', $response);
$request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource;
/**
* Resource test base class for config entities.
*
* @todo Remove this in https://www.drupal.org/node/2300677.
*/
abstract class ConfigEntityResourceTestBase extends EntityResourceTestBase {
/**
* A list of test methods to skip.
*
* @var array
*/
const SKIP_METHODS = ['testPost', 'testPatch', 'testDelete'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
if (in_array($this->name(), static::SKIP_METHODS, TRUE)) {
// Skip before installing Drupal to prevent unnecessary use of resources.
$this->markTestSkipped("Not yet supported for config entities.");
}
parent::setUp();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class ModeratedNodeJsonAnonTest extends ModeratedNodeResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class ModeratedNodeJsonBasicAuthTest extends ModeratedNodeResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class ModeratedNodeJsonCookieTest extends ModeratedNodeResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Core\Cache\Cache;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Functional\Rest\NodeResourceTestBase;
/**
* Extend the Node resource test base and apply moderation to the entity.
*/
abstract class ModeratedNodeResourceTestBase extends NodeResourceTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['content_moderation'];
/**
* The test editorial workflow.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $workflow;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
parent::setUpAuthorization($method);
switch ($method) {
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['use editorial transition publish', 'use editorial transition create_new_draft']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$entity = parent::createEntity();
if (!$this->workflow) {
$this->workflow = $this->createEditorialWorkflow();
}
$this->workflow->getTypePlugin()->addEntityTypeAndBundle($entity->getEntityTypeId(), $entity->bundle());
$this->workflow->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return array_merge(parent::getExpectedNormalizedEntity(), [
'moderation_state' => [
[
'value' => 'published',
],
],
'vid' => [
[
'value' => (int) $this->entity->getRevisionId(),
],
],
]);
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheTags() {
return Cache::mergeTags(parent::getExpectedCacheTags(), ['config:workflows.workflow.editorial']);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class ModeratedNodeXmlAnonTest extends ModeratedNodeResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class ModeratedNodeXmlBasicAuthTest extends ModeratedNodeResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class ModeratedNodeXmlCookieTest extends ModeratedNodeResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
use Drupal\Core\Field\Plugin\Field\FieldType\ChangedItem;
use Drupal\Core\Field\Plugin\Field\FieldType\CreatedItem;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Field\Plugin\Field\FieldType\IntegerItem;
use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
use Drupal\file\Plugin\Field\FieldType\FileItem;
use Drupal\image\Plugin\Field\FieldType\ImageItem;
use Drupal\options\Plugin\Field\FieldType\ListIntegerItem;
use Drupal\path\Plugin\Field\FieldType\PathItem;
use Drupal\Tests\rest\Functional\XmlNormalizationQuirksTrait;
use Drupal\user\StatusItem;
/**
* Trait for EntityResourceTestBase subclasses testing $format='xml'.
*/
trait XmlEntityNormalizationQuirksTrait {
use XmlNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
$default_normalization = parent::getExpectedNormalizedEntity();
if ($this->entity instanceof FieldableEntityInterface) {
$normalization = $this->applyXmlFieldDecodingQuirks($default_normalization);
}
else {
$normalization = $this->applyXmlConfigEntityDecodingQuirks($default_normalization);
}
$normalization = $this->applyXmlDecodingQuirks($normalization);
return $normalization;
}
/**
* Applies the XML entity field encoding quirks that remain after decoding.
*
* The XML encoding:
* - loses type data (int and bool become string)
*
* @param array $normalization
* An entity normalization.
*
* @return array
* The updated fieldable entity normalization.
*
* @see \Symfony\Component\Serializer\Encoder\XmlEncoder
*/
protected function applyXmlFieldDecodingQuirks(array $normalization) {
foreach ($this->entity->getFields(TRUE) as $field_name => $field) {
// Not every field is accessible.
if (!isset($normalization[$field_name])) {
continue;
}
for ($i = 0; $i < count($normalization[$field_name]); $i++) {
switch ($field->getItemDefinition()->getClass()) {
case BooleanItem::class:
case StatusItem::class:
// @todo Remove the StatusItem case in
// https://www.drupal.org/project/drupal/issues/2936864.
$value = &$normalization[$field_name][$i]['value'];
$value = $value === TRUE ? '1' : '0';
break;
case IntegerItem::class:
case ListIntegerItem::class:
$value = &$normalization[$field_name][$i]['value'];
$value = (string) $value;
break;
case PathItem::class:
$pid = &$normalization[$field_name][$i]['pid'];
$pid = (string) $pid;
break;
case EntityReferenceItem::class:
case FileItem::class:
$target_id = &$normalization[$field_name][$i]['target_id'];
$target_id = (string) $target_id;
break;
case ChangedItem::class:
case CreatedItem::class:
case TimestampItem::class:
$value = &$normalization[$field_name][$i]['value'];
if (is_numeric($value)) {
$value = (string) $value;
}
break;
case ImageItem::class:
$height = &$normalization[$field_name][$i]['height'];
$height = (string) $height;
$width = &$normalization[$field_name][$i]['width'];
$width = (string) $width;
$target_id = &$normalization[$field_name][$i]['target_id'];
$target_id = (string) $target_id;
break;
}
}
if (count($normalization[$field_name]) === 1) {
$normalization[$field_name] = $normalization[$field_name][0];
}
}
return $normalization;
}
/**
* Applies the XML config entity encoding quirks that remain after decoding.
*
* The XML encoding:
* - loses type data (int and bool become string)
* - converts single-item arrays into single items (non-arrays)
*
* @param array $normalization
* An entity normalization.
*
* @return array
* The updated config entity normalization.
*
* @see \Symfony\Component\Serializer\Encoder\XmlEncoder
*/
protected function applyXmlConfigEntityDecodingQuirks(array $normalization) {
$normalization = static::castToString($normalization);
// When a single dependency is listed, it's not decoded into an array.
if (isset($normalization['dependencies'])) {
foreach ($normalization['dependencies'] as $dependency_type => $dependency_list) {
if (count($dependency_list) === 1) {
$normalization['dependencies'][$dependency_type] = $dependency_list[0];
}
}
}
return $normalization;
}
/**
* {@inheritdoc}
*/
public function testPost(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
public function testPatch(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,832 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Url;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
// cspell:ignore èxample msword
/**
* Tests binary data file upload route.
*/
abstract class FileUploadResourceTestBase extends ResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest_test', 'entity_test', 'file', 'user'];
/**
* {@inheritdoc}
*/
protected static $resourceConfigId = 'file.upload';
/**
* The POST URI.
*
* @var string
*/
protected static $postUri = 'file/upload/entity_test/entity_test/field_rest_file_test';
/**
* Test file data.
*
* @var string
*/
protected $testFileData = 'Hares sit on chairs, and mules sit on stools.';
/**
* The test field storage config.
*
* @var \Drupal\field\Entity\FieldStorageConfig
*/
protected $fieldStorage;
/**
* The field config.
*
* @var \Drupal\field\Entity\FieldConfig
*/
protected $field;
/**
* The parent entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* Created file entity.
*
* @var \Drupal\file\Entity\File
*/
protected $file;
/**
* An authenticated user.
*
* @var \Drupal\user\UserInterface
*/
protected $user;
/**
* The entity storage for the 'file' entity type.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $fileStorage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->fileStorage = $this->container->get('entity_type.manager')
->getStorage('file');
// Add a file field.
$this->fieldStorage = FieldStorageConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'field_rest_file_test',
'type' => 'file',
'settings' => [
'uri_scheme' => 'public',
],
])
->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
$this->fieldStorage->save();
$this->field = FieldConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'field_rest_file_test',
'bundle' => 'entity_test',
'settings' => [
'file_directory' => 'foobar',
'file_extensions' => 'txt',
'max_filesize' => '',
],
])
->setLabel('Test file field')
->setTranslatable(FALSE);
$this->field->save();
// Create an entity that a file can be attached to.
$this->entity = EntityTest::create([
'name' => 'Llama',
'type' => 'entity_test',
]);
$this->entity->setOwnerId(isset($this->account) ? $this->account->id() : 0);
$this->entity->save();
// Provision entity_test resource.
$this->resourceConfigStorage->create([
'id' => 'entity.entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['POST'],
'formats' => [static::$format],
'authentication' => [static::$auth],
],
'status' => TRUE,
])->save();
// Provisioning the file upload REST resource without the File REST resource
// does not make sense.
$this->resourceConfigStorage->create([
'id' => 'entity.file',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET'],
'formats' => [static::$format],
'authentication' => isset(static::$auth) ? [static::$auth] : [],
],
'status' => TRUE,
])->save();
$this->refreshTestStateAfterRestConfigChange();
}
/**
* Tests using the file upload POST route.
*/
public function testPostFileUpload(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$uri = Url::fromUri('base:' . static::$postUri);
// DX: 403 when unauthorized.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
$this->setUpAuthorization('POST');
// 404 when the field name is invalid.
$invalid_uri = Url::fromUri('base:file/upload/entity_test/entity_test/field_rest_file_test_invalid');
$response = $this->fileRequest($invalid_uri, $this->testFileData);
$this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist', $response);
// This request will have the default 'application/octet-stream' content
// type header.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity();
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
// Test the file again but using 'filename' in the Content-Disposition
// header with no 'file' prefix.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt', TRUE);
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
$this->assertTrue($this->fileStorage->loadUnchanged(1)->isTemporary());
// Verify that we can create an entity that references the uploaded file.
$entity_test_post_url = Url::fromRoute('rest.entity.entity_test.POST')
->setOption('query', ['_format' => static::$format]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
$request_options[RequestOptions::BODY] = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
$response = $this->request('POST', $entity_test_post_url, $request_options);
$this->assertResourceResponse(201, FALSE, $response);
$this->assertTrue($this->fileStorage->loadUnchanged(1)->isPermanent());
$this->assertSame([
[
'target_id' => '1',
'display' => NULL,
'description' => "The most fascinating file ever!",
],
], EntityTest::load(2)->get('field_rest_file_test')->getValue());
}
/**
* Returns the normalized POST entity referencing the uploaded file.
*
* @return array
*
* @see ::testPostFileUpload()
* @see \Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase::getNormalizedPostEntity()
*/
protected function getNormalizedPostEntity() {
return [
'type' => [
[
'value' => 'entity_test',
],
],
'name' => [
[
'value' => 'Drama llama',
],
],
'field_rest_file_test' => [
[
'target_id' => 1,
'description' => 'The most fascinating file ever!',
],
],
];
}
/**
* Tests using the file upload POST route with invalid headers.
*/
public function testPostFileUploadInvalidHeaders(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// The wrong content type header should return a 415 code.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => static::$mimeType]);
$this->assertResourceErrorResponse(415, sprintf('No route found that matches "Content-Type: %s"', static::$mimeType), $response);
// An empty Content-Disposition header should return a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => FALSE]);
$this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.', $response);
// An empty filename with a context in the Content-Disposition header should
// return a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']);
$this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
// An empty filename without a context in the Content-Disposition header
// should return a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']);
$this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
// An invalid key-value pair in the Content-Disposition header should return
// a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']);
$this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
// Using filename* extended format is not currently supported.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']);
$this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header.', $response);
}
/**
* Tests using the file upload POST route with a duplicate file name.
*
* A new file should be created with a suffixed name.
*/
public function testPostFileUploadDuplicateFile(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// This request will have the default 'application/octet-stream' content
// type header.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
// Make the same request again. The file should be saved as a new file
// entity that has the same file name but a suffixed file URI.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
// Loading expected normalized data for file 2, the duplicate file.
$expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt', TRUE);
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
}
/**
* Tests using the file upload POST route twice, simulating a race condition.
*
* A validation error should occur when the filenames are not unique.
*/
public function testPostFileUploadDuplicateFileRaceCondition(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// This request will have the default 'application/octet-stream' content
// type header.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
// Simulate a race condition where two files are uploaded at almost the same
// time, by removing the first uploaded file from disk (leaving the entry in
// the file_managed table) before trying to upload another file with the
// same name.
unlink(\Drupal::service('file_system')->realpath('public://foobar/example.txt'));
// Make the same request again. The upload should fail validation.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file public://foobar/example.txt already exists. Enter a unique file URI."), $response);
}
/**
* Tests using the file upload route with any path prefixes being stripped.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Directives
*/
public function testFileUploadStrippedFilePath(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity();
$this->assertResponseData($expected, $response);
// Check the actual file data. It should have been written to the configured
// directory, not /foobar/directory/example.txt.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="../../example_2.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(2, 'example_2.txt', TRUE);
$this->assertResponseData($expected, $response);
// Check the actual file data. It should have been written to the configured
// directory, not /foobar/directory/example.txt.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_2.txt'));
$this->assertFileDoesNotExist('../../example_2.txt');
// Check a path from the root. Extensions have to be empty to allow a file
// with no extension to pass validation.
$this->field->setSetting('file_extensions', '')
->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="/etc/passwd"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(3, 'passwd', TRUE);
// This mime will be guessed as there is no extension.
$expected['filemime'][0]['value'] = 'application/octet-stream';
$this->assertResponseData($expected, $response);
// Check the actual file data. It should have been written to the configured
// directory, not /foobar/directory/example.txt.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/passwd'));
}
/**
* Tests using the file upload route with a unicode file name.
*/
public function testFileUploadUnicodeFilename(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// It is important that the filename starts with a unicode character. See
// https://bugs.php.net/bug.php?id=77239.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="Èxample-✓.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(1, 'Èxample-✓.txt', TRUE);
$this->assertResponseData($expected, $response);
$this->assertSame($this->testFileData, file_get_contents('public://foobar/Èxample-✓.txt'));
}
/**
* Tests using the file upload route with a zero byte file.
*/
public function testFileUploadZeroByteFile(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// Test with a zero byte file.
$response = $this->fileRequest($uri, NULL);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity();
// Modify the default expected data to account for the 0 byte file.
$expected['filesize'][0]['value'] = 0;
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame('', file_get_contents('public://foobar/example.txt'));
}
/**
* Tests using the file upload route with an invalid file type.
*/
public function testFileUploadInvalidFileType(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// Test with a JSON file.
$response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']);
$this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: <em class=\"placeholder\">txt</em>."), $response);
// Make sure that no file was saved.
$this->assertEmpty(File::load(1));
$this->assertFileDoesNotExist('public://foobar/example.txt');
}
/**
* Tests using the file upload route with a file size larger than allowed.
*/
public function testFileUploadLargerFileSize(): void {
// Set a limit of 50 bytes.
$this->field->setSetting('max_filesize', 50)
->save();
$this->refreshTestStateAfterRestConfigChange();
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// Generate a string larger than the 50 byte limit set.
$response = $this->fileRequest($uri, $this->randomString(100));
$this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file is <em class=\"placeholder\">100 bytes</em> exceeding the maximum file size of <em class=\"placeholder\">50 bytes</em>."), $response);
// Make sure that no file was saved.
$this->assertEmpty(File::load(1));
$this->assertFileDoesNotExist('public://foobar/example.txt');
}
/**
* Tests using the file upload POST route with malicious extensions.
*/
public function testFileUploadMaliciousExtension(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
// Allow all file uploads but system.file::allow_insecure_uploads is set to
// FALSE.
$this->field->setSetting('file_extensions', '')->save();
$this->refreshTestStateAfterRestConfigChange();
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
$php_string = '<?php print "Drupal"; ?>';
// Test using a masked exploit file.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']);
// The filename is not munged because .txt is added and it is a known
// extension to apache.
$expected = $this->getExpectedNormalizedEntity(1, 'example.php_.txt', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example.php_.txt');
// Add .php and .txt as allowed extensions. Since 'allow_insecure_uploads'
// is FALSE, .php files should be renamed to have a .txt extension.
$this->field->setSetting('file_extensions', 'php txt')->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_2.php"']);
$expected = $this->getExpectedNormalizedEntity(2, 'example_2.php_.txt', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_2.php_.txt');
$this->assertFileDoesNotExist('public://foobar/example_2.php');
// Allow .doc file uploads and ensure even a mis-configured apache will not
// fallback to php because the filename will be munged.
$this->field->setSetting('file_extensions', 'doc')->save();
$this->refreshTestStateAfterRestConfigChange();
// Test using a masked exploit file.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_3.php.doc"']);
// The filename is munged.
$expected = $this->getExpectedNormalizedEntity(3, 'example_3.php_.doc', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should be 'application/msword'.
$expected['filemime'][0]['value'] = 'application/msword';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_3.php_.doc');
$this->assertFileDoesNotExist('public://foobar/example_3.php.doc');
// Test that a dangerous extension such as .php is munged even if it is in
// the list of allowed extensions.
$this->field->setSetting('file_extensions', 'doc php')->save();
$this->refreshTestStateAfterRestConfigChange();
// Test using a masked exploit file.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_4.php.doc"']);
// The filename is munged.
$expected = $this->getExpectedNormalizedEntity(4, 'example_4.php_.doc', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should be 'application/msword'.
$expected['filemime'][0]['value'] = 'application/msword';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_4.php_.doc');
$this->assertFileDoesNotExist('public://foobar/example_4.php.doc');
// Dangerous extensions are munged even when all extensions are allowed.
$this->field->setSetting('file_extensions', '')->save();
$this->rebuildAll();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_5.php.png"']);
$expected = $this->getExpectedNormalizedEntity(5, 'example_5.php_.png', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should still see this as a PNG image.
$expected['filemime'][0]['value'] = 'image/png';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_5.php_.png');
// Dangerous extensions are munged if is renamed to end in .txt.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_6.cgi.png.txt"']);
$expected = $this->getExpectedNormalizedEntity(6, 'example_6.cgi_.png_.txt', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should also now be text.
$expected['filemime'][0]['value'] = 'text/plain';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_6.cgi_.png_.txt');
// Add .php as an allowed extension without .txt. Since insecure uploads are
// not allowed, .php files will be rejected.
$this->field->setSetting('file_extensions', 'php')->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_7.php"']);
$this->assertResourceErrorResponse(422, "Unprocessable Entity: file validation failed.\nFor security reasons, your upload has been rejected.", $response);
// Make sure that no file was saved.
$this->assertFileDoesNotExist('public://foobar/example_7.php');
$this->assertFileDoesNotExist('public://foobar/example_7.php.txt');
// Now allow insecure uploads.
\Drupal::configFactory()
->getEditable('system.file')
->set('allow_insecure_uploads', TRUE)
->save();
// Allow all file uploads. This is very insecure.
$this->field->setSetting('file_extensions', '')->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_7.php"']);
$expected = $this->getExpectedNormalizedEntity(7, 'example_7.php', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should also now be PHP.
$expected['filemime'][0]['value'] = 'application/x-httpd-php';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_7.php');
}
/**
* Tests using the file upload POST route no extension configured.
*/
public function testFileUploadNoExtensionSetting(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
$this->field->setSetting('file_extensions', '')
->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
$expected = $this->getExpectedNormalizedEntity(1, 'example.txt', TRUE);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example.txt');
}
/**
* {@inheritdoc}
*/
protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
// The file upload resource only accepts binary data, so there are no
// normalization edge cases to test, as there are no normalized entity
// representations incoming.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'.";
}
/**
* Gets the expected file entity.
*
* @param int $fid
* The file ID to load and create normalized data for.
* @param string $expected_filename
* The expected filename for the stored file.
* @param bool $expected_as_filename
* Whether the expected filename should be the filename property too.
*
* @return array
* The expected normalized data array.
*/
protected function getExpectedNormalizedEntity($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE) {
$author = User::load(static::$auth ? $this->account->id() : 0);
$file = File::load($fid);
$this->assertInstanceOf(FileInterface::class, $file);
$expected_normalization = [
'fid' => [
[
'value' => (int) $file->id(),
],
],
'uuid' => [
[
'value' => $file->uuid(),
],
],
'langcode' => [
[
'value' => 'en',
],
],
'uid' => [
[
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(),
],
],
'filename' => [
[
'value' => $expected_as_filename ? $expected_filename : 'example.txt',
],
],
'uri' => [
[
'value' => 'public://foobar/' . $expected_filename,
'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename),
],
],
'filemime' => [
[
'value' => 'text/plain',
],
],
'filesize' => [
[
'value' => strlen($this->testFileData),
],
],
'status' => [
[
'value' => FALSE,
],
],
'created' => [
[
'value' => (new \DateTime())->setTimestamp($file->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'format' => \DateTime::RFC3339,
],
],
'changed' => [
[
'value' => (new \DateTime())->setTimestamp($file->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'format' => \DateTime::RFC3339,
],
],
];
return $expected_normalization;
}
/**
* Performs a file upload request. Wraps the Guzzle HTTP client.
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param \Drupal\Core\Url $url
* URL to request.
* @param string $file_contents
* The file contents to send as the request body.
* @param array $headers
* Additional headers to send with the request. Defaults will be added for
* Content-Type and Content-Disposition. In order to remove the defaults set
* the header value to FALSE.
*
* @return \Psr\Http\Message\ResponseInterface
*/
protected function fileRequest(Url $url, $file_contents, array $headers = []) {
// Set the format for the response.
$url->setOption('query', ['_format' => static::$format]);
$request_options = [];
$headers = $headers + [
// Set the required (and only accepted) content type for the request.
'Content-Type' => 'application/octet-stream',
// Set the required Content-Disposition header for the file name.
'Content-Disposition' => 'file; filename="example.txt"',
];
$request_options[RequestOptions::HEADERS] = array_filter($headers, function ($value) {
return $value !== FALSE;
});
$request_options[RequestOptions::BODY] = $file_contents;
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
return $this->request('POST', $url, $request_options);
}
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['view test entity']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities', 'access content']);
break;
}
}
/**
* Asserts expected normalized data matches response data.
*
* @param array $expected
* The expected data.
* @param \Psr\Http\Message\ResponseInterface $response
* The file upload response.
*/
protected function assertResponseData(array $expected, ResponseInterface $response) {
static::recursiveKSort($expected);
$actual = $this->serializer->decode((string) $response->getBody(), static::$format);
static::recursiveKSort($actual);
$this->assertSame($expected, $actual);
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// There is cacheability metadata to check as file uploads only allows POST
// requests, which will not return cacheable responses.
return new CacheableMetadata();
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for rest.
*
* @group rest
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Session\AccountInterface;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
/**
* Tests the structure of a REST resource.
*
* @group rest
* @group #slow
*/
class ResourceTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['rest', 'entity_test', 'rest_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create an entity programmatic.
$this->entity = EntityTest::create([
'name' => $this->randomMachineName(),
'user_id' => 1,
'field_test_text' => [
0 => [
'value' => $this->randomString(),
'format' => 'plain_text',
],
],
]);
$this->entity->save();
Role::load(AccountInterface::ANONYMOUS_ROLE)
->grantPermission('view test entity')
->save();
}
/**
* Tests that a resource without formats cannot be enabled.
*/
public function testFormats(): void {
RestResourceConfig::create([
'id' => 'entity.entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => [
'basic_auth',
],
],
],
])->save();
// Verify that accessing the resource returns 406.
$this->drupalGet($this->entity->toUrl()->setRouteParameter('_format', 'json'));
// \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
// non-REST route a match, but a lower quality one: no format restrictions
// means there's always a match and hence when there is no matching REST
// route, the non-REST route is used, but can't render into
// application/json, so it returns a 406.
$this->assertSession()->statusCodeEquals(406);
}
/**
* Tests that a resource without authentication cannot be enabled.
*/
public function testAuthentication(): void {
RestResourceConfig::create([
'id' => 'entity.entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
],
],
])->save();
// Verify that accessing the resource returns 401.
$this->drupalGet($this->entity->toUrl()->setRouteParameter('_format', 'json'));
// \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
// non-REST route a match, but a lower quality one: no format restrictions
// means there's always a match and hence when there is no matching REST
// route, the non-REST route is used, but can't render into
// application/json, so it returns a 406.
$this->assertSession()->statusCodeEquals(406);
}
/**
* Tests that serialization_class is optional.
*/
public function testSerializationClassIsOptional(): void {
RestResourceConfig::create([
'id' => 'serialization_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'POST' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
])->save();
\Drupal::service('router.builder')->rebuildIfNeeded();
Role::load(RoleInterface::ANONYMOUS_ID)
->grantPermission('restful post serialization_test')
->save();
$serialized = $this->container->get('serializer')->serialize(['foo', 'bar'], 'json');
$request_options = [
RequestOptions::HEADERS => ['Content-Type' => 'application/json'],
RequestOptions::BODY => $serialized,
];
/** @var \GuzzleHttp\ClientInterface $client */
$client = $this->getSession()->getDriver()->getClient()->getClient();
$response = $client->request('POST', $this->buildUrl('serialization_test', ['query' => ['_format' => 'json']]), $request_options);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('["foo","bar"]', (string) $response->getBody());
}
/**
* Tests that resource URI paths are formatted properly.
*/
public function testUriPaths(): void {
/** @var \Drupal\rest\Plugin\Type\ResourcePluginManager $manager */
$manager = \Drupal::service('plugin.manager.rest');
foreach ($manager->getDefinitions() as $resource => $definition) {
foreach ($definition['uri_paths'] as $key => $uri_path) {
$this->assertStringNotContainsString('//', $uri_path, 'The resource URI path does not have duplicate slashes.');
}
}
}
}

View File

@@ -0,0 +1,506 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Behat\Mink\Driver\BrowserKitDriver;
use Drupal\Core\Url;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
/**
* Subclass this for every REST resource, every format and every auth provider.
*
* For more guidance see
* \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase
* which has recommendations for testing the
* \Drupal\rest\Plugin\rest\resource\EntityResource REST resource for every
* format and every auth provider. It's a special case (because that single REST
* resource generates supports not just one thing, but many things  multiple
* entity types), but the same principles apply.
*/
abstract class ResourceTestBase extends BrowserTestBase {
/**
* The format to use in this test.
*
* A format is the combination of a certain normalizer and a certain
* serializer.
*
* @see https://www.drupal.org/developing/api/8/serialization
*
* (The default is 'json' because that doesn't depend on any module.)
*
* @var string
*/
protected static $format = 'json';
/**
* The MIME type that corresponds to $format.
*
* (Sadly this cannot be computed automatically yet.)
*
* @var string
*/
protected static $mimeType = 'application/json';
/**
* The authentication mechanism to use in this test.
*
* (The default is 'cookie' because that doesn't depend on any module.)
*
* @var string
*/
protected static $auth = FALSE;
/**
* The REST Resource Config entity ID under test (i.e. a resource type).
*
* The REST Resource plugin ID can be calculated from this.
*
* @var string
*
* @see \Drupal\rest\Entity\RestResourceConfig::__construct()
*/
protected static $resourceConfigId = NULL;
/**
* The account to use for authentication, if any.
*
* @var null|\Drupal\Core\Session\AccountInterface
*/
protected $account = NULL;
/**
* The REST resource config entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $resourceConfigStorage;
/**
* The serializer service.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['rest'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->serializer = $this->container->get('serializer');
// Ensure the anonymous user role has no permissions at all.
$user_role = Role::load(RoleInterface::ANONYMOUS_ID);
foreach ($user_role->getPermissions() as $permission) {
$user_role->revokePermission($permission);
}
$user_role->save();
assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
if (static::$auth !== FALSE) {
// Ensure the authenticated user role has no permissions at all.
$user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
foreach ($user_role->getPermissions() as $permission) {
$user_role->revokePermission($permission);
}
$user_role->save();
assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
// Create an account.
$this->account = $this->createUser();
}
else {
// Otherwise, also create an account, so that any test involving User
// entities will have the same user IDs regardless of authentication.
$this->createUser();
}
$this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config');
// Ensure there's a clean slate: delete all REST resource config entities.
$this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple());
$this->refreshTestStateAfterRestConfigChange();
}
/**
* Provisions the REST resource under test.
*
* @param string[] $formats
* The allowed formats for this resource.
* @param string[] $authentication
* The allowed authentication providers for this resource.
* @param string[] $methods
* The allowed methods for this resource.
*/
protected function provisionResource($formats = [], $authentication = [], array $methods = ['GET', 'POST', 'PATCH', 'DELETE']) {
$this->resourceConfigStorage->create([
'id' => static::$resourceConfigId,
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => $methods,
'formats' => $formats,
'authentication' => $authentication,
],
'status' => TRUE,
])->save();
$this->refreshTestStateAfterRestConfigChange();
}
/**
* Refreshes the state of the tester to be in sync with the testee.
*
* Should be called after every change made to:
* - RestResourceConfig entities
*/
protected function refreshTestStateAfterRestConfigChange() {
// Ensure that the cache tags invalidator has its internal values reset.
// Otherwise the http_response cache tag invalidation won't work.
$this->refreshVariables();
// Tests using this base class may trigger route rebuilds due to changes to
// RestResourceConfig entities. Ensure the test generates routes using an
// up-to-date router.
\Drupal::service('router.builder')->rebuildIfNeeded();
}
/**
* Return the expected error message.
*
* @param string $method
* The HTTP method (GET, POST, PATCH, DELETE).
*
* @return string
* The error string.
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
$resource_plugin_id = str_replace('.', ':', static::$resourceConfigId);
$permission = 'restful ' . strtolower($method) . ' ' . $resource_plugin_id;
return "The '$permission' permission is required.";
}
/**
* Sets up the necessary authorization.
*
* In case of a test verifying publicly accessible REST resources: grant
* permissions to the anonymous user role.
*
* In case of a test verifying behavior when using a particular authentication
* provider: create a user with a particular set of permissions.
*
* Because of the $method parameter, it's possible to first set up
* authentication for only GET, then add POST, et cetera. This then also
* allows for verifying a 403 in case of missing authorization.
*
* @param string $method
* The HTTP method for which to set up authentication.
*
* @see ::grantPermissionsToAnonymousRole()
* @see ::grantPermissionsToAuthenticatedRole()
*/
abstract protected function setUpAuthorization($method);
/**
* Verifies the error response in case of missing authentication.
*
* @param string $method
* HTTP method.
* @param \Psr\Http\Message\ResponseInterface $response
* The response to assert.
*/
abstract protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response);
/**
* Asserts normalization-specific edge cases.
*
* (Should be called before sending a well-formed request.)
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*/
abstract protected function assertNormalizationEdgeCases($method, Url $url, array $request_options);
/**
* Asserts authentication provider-specific edge cases.
*
* (Should be called before sending a well-formed request.)
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*/
abstract protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options);
/**
* Returns the expected cacheability of an unauthorized access response.
*
* @return \Drupal\Core\Cache\RefinableCacheableDependencyInterface
* The expected cacheability.
*/
abstract protected function getExpectedUnauthorizedAccessCacheability();
/**
* Initializes authentication.
*
* E.g. for cookie authentication, we first need to get a cookie.
*/
protected function initAuthentication() {}
/**
* Returns Guzzle request options for authentication.
*
* @param string $method
* The HTTP method for this authenticated request.
*
* @return array
* Guzzle request options to use for authentication.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getAuthenticationRequestOptions($method) {
return [];
}
/**
* Grants permissions to the anonymous role.
*
* @param string[] $permissions
* Permissions to grant.
*/
protected function grantPermissionsToAnonymousRole(array $permissions) {
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions);
}
/**
* Grants permissions to the authenticated role.
*
* @param string[] $permissions
* Permissions to grant.
*/
protected function grantPermissionsToAuthenticatedRole(array $permissions) {
$this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
}
/**
* Grants permissions to the tested role: anonymous or authenticated.
*
* @param string[] $permissions
* Permissions to grant.
*
* @see ::grantPermissionsToAuthenticatedRole()
* @see ::grantPermissionsToAnonymousRole()
*/
protected function grantPermissionsToTestedRole(array $permissions) {
if (static::$auth) {
$this->grantPermissionsToAuthenticatedRole($permissions);
}
else {
$this->grantPermissionsToAnonymousRole($permissions);
}
}
/**
* Performs a HTTP request. Wraps the Guzzle HTTP client.
*
* Why wrap the Guzzle HTTP client? Because we want to keep the actual test
* code as simple as possible, and hence not require them to specify the
* 'http_errors = FALSE' request option, nor do we want them to have to
* convert Drupal Url objects to strings.
*
* We also don't want to follow redirects automatically, to ensure these tests
* are able to detect when redirects are added or removed.
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*
* @return \Psr\Http\Message\ResponseInterface
*/
protected function request($method, Url $url, array $request_options) {
$request_options[RequestOptions::HTTP_ERRORS] = FALSE;
$request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;
$request_options = $this->decorateWithXdebugCookie($request_options);
$client = $this->getHttpClient();
return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
}
/**
* Asserts that a resource response has the given status code and body.
*
* @param int $expected_status_code
* The expected response status.
* @param string|false $expected_body
* The expected response body. FALSE in case this should not be asserted.
* @param \Psr\Http\Message\ResponseInterface $response
* The response to assert.
* @param string[]|false $expected_cache_tags
* (optional) The expected cache tags in the X-Drupal-Cache-Tags response
* header, or FALSE if that header should be absent. Defaults to FALSE.
* @param string[]|false $expected_cache_contexts
* (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
* response header, or FALSE if that header should be absent. Defaults to
* FALSE.
* @param string|false $expected_page_cache_header_value
* (optional) The expected X-Drupal-Cache response header value, or FALSE if
* that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
* to FALSE.
* @param string|false $expected_dynamic_page_cache_header_value
* (optional) The expected X-Drupal-Dynamic-Cache response header value, or
* FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
* Defaults to FALSE.
*/
protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
$this->assertSame($expected_status_code, $response->getStatusCode());
if ($expected_status_code === 204) {
// DELETE responses should not include a Content-Type header. But Apache
// sets it to 'text/html' by default. We also cannot detect the presence
// of Apache either here in the CLI. For now having this documented here
// is all we can do.
// $this->assertFalse($response->hasHeader('Content-Type'));
$this->assertSame('', (string) $response->getBody());
}
else {
$this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
if ($expected_body !== FALSE) {
$this->assertSame($expected_body, (string) $response->getBody());
}
}
// Expected cache tags: X-Drupal-Cache-Tags header.
$this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags'));
if (is_array($expected_cache_tags)) {
$this->assertEqualsCanonicalizing($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
}
// Expected cache contexts: X-Drupal-Cache-Contexts header.
$this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts'));
if (is_array($expected_cache_contexts)) {
$optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
$this->assertEqualsCanonicalizing($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
}
// Expected Page Cache header value: X-Drupal-Cache header.
if ($expected_page_cache_header_value !== FALSE) {
$this->assertTrue($response->hasHeader('X-Drupal-Cache'));
$this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
}
else {
$this->assertFalse($response->hasHeader('X-Drupal-Cache'));
}
// Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
if ($expected_dynamic_page_cache_header_value !== FALSE) {
$this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
$this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
}
else {
$this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
}
}
/**
* Asserts that a resource error response has the given message.
*
* @param int $expected_status_code
* The expected response status.
* @param string $expected_message
* The expected error message.
* @param \Psr\Http\Message\ResponseInterface $response
* The error response to assert.
* @param string[]|false $expected_cache_tags
* (optional) The expected cache tags in the X-Drupal-Cache-Tags response
* header, or FALSE if that header should be absent. Defaults to FALSE.
* @param string[]|false $expected_cache_contexts
* (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
* response header, or FALSE if that header should be absent. Defaults to
* FALSE.
* @param string|false $expected_page_cache_header_value
* (optional) The expected X-Drupal-Cache response header value, or FALSE if
* that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
* to FALSE.
* @param string|false $expected_dynamic_page_cache_header_value
* (optional) The expected X-Drupal-Dynamic-Cache response header value, or
* FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
* Defaults to FALSE.
*/
protected function assertResourceErrorResponse($expected_status_code, $expected_message, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
$expected_body = ($expected_message !== FALSE) ? $this->serializer->encode(['message' => $expected_message], static::$format) : FALSE;
$this->assertResourceResponse($expected_status_code, $expected_body, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value);
}
/**
* Adds the Xdebug cookie to the request options.
*
* @param array $request_options
* The request options.
*
* @return array
* Request options updated with the Xdebug cookie if present.
*/
protected function decorateWithXdebugCookie(array $request_options) {
$session = $this->getSession();
$driver = $session->getDriver();
if ($driver instanceof BrowserKitDriver) {
$client = $driver->getClient();
foreach ($client->getCookieJar()->all() as $cookie) {
if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) {
$request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue();
}
else {
$request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue();
}
}
}
return $request_options;
}
/**
* Recursively sorts an array by key.
*
* @param array $array
* An array to sort.
*/
protected static function recursiveKSort(array &$array) {
// First, sort the main array.
ksort($array);
// Then check for child arrays.
foreach ($array as $key => &$value) {
if (is_array($value)) {
static::recursiveKSort($value);
}
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class RestResourceConfigJsonAnonTest extends RestResourceConfigResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class RestResourceConfigJsonBasicAuthTest extends RestResourceConfigResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class RestResourceConfigJsonCookieTest extends RestResourceConfigResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
use Drupal\rest\Entity\RestResourceConfig;
abstract class RestResourceConfigResourceTestBase extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['dblog'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'rest_resource_config';
/**
* @var \Drupal\rest\RestResourceConfigInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer rest resources']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$rest_resource_config = RestResourceConfig::create([
'id' => 'llama',
'plugin_id' => 'dblog',
'granularity' => 'method',
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
]);
$rest_resource_config->save();
return $rest_resource_config;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'uuid' => $this->entity->uuid(),
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [
'module' => [
'dblog',
'serialization',
'user',
],
],
'id' => 'llama',
'plugin_id' => 'dblog',
'granularity' => 'method',
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
return [
'user.permissions',
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class RestResourceConfigXmlAnonTest extends RestResourceConfigResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class RestResourceConfigXmlBasicAuthTest extends RestResourceConfigResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class RestResourceConfigXmlCookieTest extends RestResourceConfigResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\node\Entity\Node;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
/**
* Tests the display of an excluded field that is used as a token.
*
* @group rest
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class ExcludedFieldTokenTest extends ViewTestBase {
/**
* @var \Drupal\views\ViewExecutable
*/
protected $view;
/**
* The views that are used by this test.
*
* @var array
*/
public static $testViews = ['test_excluded_field_token_display'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The modules that need to be installed for this test.
*
* @var array
*/
protected static $modules = [
'entity_test',
'rest_test_views',
'node',
'field',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
// Create some test content.
for ($i = 1; $i <= 10; $i++) {
Node::create([
'type' => 'article',
'title' => 'Article test ' . $i,
])->save();
}
$this->enableViewsTestModule();
$this->view = Views::getView('test_excluded_field_token_display');
$this->view->setDisplay('rest_export_1');
}
/**
* Tests the display of an excluded title field when used as a token.
*/
public function testExcludedTitleTokenDisplay(): void {
$actual_json = $this->drupalGet($this->view->getPath(), ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$expected = [
['nothing' => 'Article test 10'],
['nothing' => 'Article test 9'],
['nothing' => 'Article test 8'],
['nothing' => 'Article test 7'],
['nothing' => 'Article test 6'],
['nothing' => 'Article test 5'],
['nothing' => 'Article test 4'],
['nothing' => 'Article test 3'],
['nothing' => 'Article test 2'],
['nothing' => 'Article test 1'],
];
$this->assertSame(json_encode($expected), $actual_json);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\node\Entity\Node;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
/**
* Tests the display of counter field.
*
* @group rest
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class FieldCounterTest extends ViewTestBase {
/**
* @var \Drupal\views\ViewExecutable
*/
protected $view;
/**
* The views that are used by this test.
*
* @var array
*/
public static $testViews = ['test_field_counter_display'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The modules that need to be installed for this test.
*
* @var array
*/
protected static $modules = [
'entity_test',
'rest_test_views',
'node',
'field',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
// Create some test content.
for ($i = 1; $i <= 10; $i++) {
Node::create([
'type' => 'article',
'title' => 'Article test ' . $i,
])->save();
}
$this->enableViewsTestModule();
$this->view = Views::getView('test_field_counter_display');
$this->view->setDisplay('rest_export_1');
}
/**
* Tests the display of an excluded title field when used as a token.
*/
public function testExcludedTitleTokenDisplay(): void {
$actual_json = $this->drupalGet($this->view->getPath(), ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$expected = [
['counter' => '1'],
['counter' => '2'],
['counter' => '3'],
['counter' => '4'],
['counter' => '5'],
['counter' => '6'],
['counter' => '7'],
['counter' => '8'],
['counter' => '9'],
['counter' => '10'],
];
$this->assertSame(json_encode($expected), $actual_json);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Entity\View;
/**
* Tests authentication for REST display.
*
* @group rest
*/
class RestExportAuthTest extends ViewTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest', 'views_ui', 'basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = []): void {
parent::setUp($import_test_views, $modules);
$this->drupalLogin($this->drupalCreateUser(['administer views']));
}
/**
* Checks that correct authentication providers are available for choosing.
*
* @link https://www.drupal.org/node/2825204
*/
public function testAuthProvidersOptions(): void {
$view_id = 'test_view_rest_export';
$view_label = 'Test view (REST export)';
$view_display = 'rest_export_1';
$view_rest_path = 'test-view/rest-export';
// Create new view.
$this->drupalGet('admin/structure/views/add');
$this->submitForm([
'id' => $view_id,
'label' => $view_label,
'show[wizard_key]' => 'users',
'rest_export[path]' => $view_rest_path,
'rest_export[create]' => TRUE,
], 'Save and edit');
$this->drupalGet("admin/structure/views/nojs/display/$view_id/$view_display/auth");
// The "basic_auth" will always be available since module,
// providing it, has the same name.
$this->assertSession()->fieldExists('edit-auth-basic-auth');
// The "cookie" authentication provider defined by "user" module.
$this->assertSession()->fieldExists('edit-auth-cookie');
// Wrong behavior in "getAuthOptions()" method makes this option available
// instead of "cookie".
// @see \Drupal\rest\Plugin\views\display\RestExport::getAuthOptions()
$this->assertSession()->fieldNotExists('edit-auth-user');
$this->submitForm(['auth[basic_auth]' => 1, 'auth[cookie]' => 1], 'Apply');
$this->submitForm([], 'Save');
$view = View::load($view_id);
$this->assertEquals(['basic_auth', 'cookie'], $view->getDisplay('rest_export_1')['display_options']['auth']);
}
}

View File

@@ -0,0 +1,589 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\Cache;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Entity\View;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Views;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
/**
* Tests the serializer style plugin.
*
* @group rest
* @group #slow
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\style\Serializer
* @see \Drupal\rest\Plugin\views\row\DataEntityRow
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class StyleSerializerEntityTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* Modules to install.
*
* @var array
*/
protected static $modules = [
'views_ui',
'entity_test',
'rest_test_views',
'text',
'field',
'language',
'basic_auth',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_serializer_display_field', 'test_serializer_display_entity', 'test_serializer_display_entity_translated', 'test_serializer_node_display_field', 'test_serializer_node_exposed_filter', 'test_serializer_shared_path'];
/**
* A user with administrative privileges to look at test entity and configure views.
*/
protected $adminUser;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
$this->adminUser = $this->drupalCreateUser([
'administer views',
'administer entity_test content',
'access user profiles',
'view test entity',
]);
// Save some entity_test entities.
for ($i = 1; $i <= 10; $i++) {
EntityTest::create(['name' => 'test_' . $i, 'user_id' => $this->adminUser->id()])->save();
}
$this->enableViewsTestModule();
$this->renderer = \Drupal::service('renderer');
}
/**
* Checks the behavior of the Serializer callback paths and row plugins.
*/
public function testSerializerResponses(): void {
// Test the serialize callback.
$view = Views::getView('test_serializer_display_field');
$view->initDisplay();
$this->executeView($view);
$actual_json = $this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertCacheTags($view->getCacheTags());
$this->assertCacheContexts(['languages:language_interface', 'theme', 'request_format']);
// @todo Due to https://www.drupal.org/node/2352009 we can't yet test the
// propagation of cache max-age.
// Test the http Content-type.
$headers = $this->getSession()->getResponseHeaders();
$this->assertSame(['application/json'], $headers['Content-Type']);
$expected = [];
foreach ($view->result as $row) {
$expected_row = [];
foreach ($view->field as $id => $field) {
$expected_row[$id] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertSame(json_encode($expected), $actual_json, 'The expected JSON output was found.');
// Test that the rendered output and the preview output are the same.
$view->destroy();
$view->setDisplay('rest_export_1');
// Mock the request content type by setting it on the display handler.
$view->display_handler->setContentType('json');
$output = $view->preview();
$this->assertSame((string) $this->renderer->renderRoot($output), $actual_json, 'The expected JSON preview output was found.');
// Test a 403 callback.
$this->drupalGet('test/serialize/denied', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(403);
// Test the entity rows.
$view = Views::getView('test_serializer_display_entity');
$view->initDisplay();
$this->executeView($view);
// Get the serializer service.
$serializer = $this->container->get('serializer');
$entities = [];
foreach ($view->result as $row) {
$entities[] = $row->_entity;
}
$expected = $serializer->serialize($entities, 'json');
$actual_json = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame($expected, $actual_json, 'The expected JSON output was found.');
$expected_cache_tags = $view->getCacheTags();
$expected_cache_tags[] = 'entity_test_list';
/** @var \Drupal\Core\Entity\EntityInterface $entity */
foreach ($entities as $entity) {
$expected_cache_tags = Cache::mergeTags($expected_cache_tags, $entity->getCacheTags());
}
$this->assertCacheTags($expected_cache_tags);
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
// Change the format to xml.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', [
'type' => 'serializer',
'options' => [
'uses_fields' => FALSE,
'formats' => [
'xml' => 'xml',
],
],
]);
$view->save();
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]);
$this->assertSame(trim($expected), $actual_xml);
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
// Allow multiple formats.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', [
'type' => 'serializer',
'options' => [
'uses_fields' => FALSE,
'formats' => [
'xml' => 'xml',
'json' => 'json',
],
],
]);
$view->save();
$expected = $serializer->serialize($entities, 'json');
$actual_json = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]);
$this->assertSame($expected, $actual_json, 'The expected JSON output was found.');
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]);
$this->assertSame(trim($expected), $actual_xml);
}
/**
* Sets up a request on the request stack with a specified format.
*
* @param string $format
* The new request format.
*/
protected function addRequestWithFormat($format) {
$request = \Drupal::request();
$request = clone $request;
$request->setRequestFormat($format);
\Drupal::requestStack()->push($request);
}
/**
* Tests REST export with views render caching enabled.
*/
public function testRestRenderCaching(): void {
$this->drupalLogin($this->adminUser);
/** @var \Drupal\Core\Render\RenderCacheInterface $render_cache */
$render_cache = \Drupal::service('render_cache');
// Enable render caching for the views.
/** @var \Drupal\views\ViewEntityInterface $storage */
$storage = View::load('test_serializer_display_entity');
$options = &$storage->getDisplay('default');
$options['display_options']['cache'] = [
'type' => 'tag',
];
$storage->save();
$original = DisplayPluginBase::buildBasicRenderable('test_serializer_display_entity', 'rest_export_1');
// Ensure that there is no corresponding render cache item yet.
$original['#cache'] += ['contexts' => []];
$original['#cache']['contexts'] = Cache::mergeContexts($original['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']);
$cache_tags = [
'config:views.view.test_serializer_display_entity',
'entity_test:1',
'entity_test:10',
'entity_test:2',
'entity_test:3',
'entity_test:4',
'entity_test:5',
'entity_test:6',
'entity_test:7',
'entity_test:8',
'entity_test:9',
'entity_test_list',
];
$cache_contexts = [
'entity_test_view_grants',
'languages:language_interface',
'theme',
'request_format',
];
$this->assertFalse($render_cache->get($original));
// Request the page, once in XML and once in JSON to ensure that the caching
// varies by it.
$result1 = Json::decode($this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]));
$this->addRequestWithFormat('json');
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
$result_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]);
$this->addRequestWithFormat('xml');
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
// Ensure that the XML output is different from the JSON one.
$this->assertNotEquals($result1, $result_xml);
// Ensure that the cached page works.
$result2 = Json::decode($this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]));
$this->addRequestWithFormat('json');
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertEquals($result1, $result2);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
// Create a new entity and ensure that the cache tags are taken over.
EntityTest::create(['name' => 'test_11', 'user_id' => $this->adminUser->id()])->save();
$result3 = Json::decode($this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]));
$this->addRequestWithFormat('json');
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertNotEquals($result2, $result3);
// Add the new entity cache tag and remove the first one, because we just
// show 10 items in total.
$cache_tags[] = 'entity_test:11';
unset($cache_tags[array_search('entity_test:1', $cache_tags)]);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
}
/**
* Tests the response format configuration.
*/
public function testResponseFormatConfiguration(): void {
$this->drupalLogin($this->adminUser);
$style_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/style_options';
// Ensure a request with no format returns 406 Not Acceptable.
$this->drupalGet('test/serialize/field');
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
// Select only 'xml' as an accepted format.
$this->drupalGet($style_options);
$this->submitForm(['style_options[formats][xml]' => 'xml'], 'Apply');
$this->submitForm([], 'Save');
// Ensure a request for JSON returns 406 Not Acceptable.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertSession()->statusCodeEquals(406);
// Ensure a request for XML returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'xml']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertSession()->statusCodeEquals(200);
// Add 'json' as an accepted format, so we have multiple.
$this->drupalGet($style_options);
$this->submitForm(['style_options[formats][json]' => 'json'], 'Apply');
$this->submitForm([], 'Save');
// Should return a 406. Emulates a sample Firefox header.
$this->drupalGet('test/serialize/field', [], ['Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
// Ensure a request for HTML returns 406 Not Acceptable.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'html']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
// Ensure a request for JSON returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertSession()->statusCodeEquals(200);
// Ensure a request XML returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'xml']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertSession()->statusCodeEquals(200);
// Now configure no format, so both serialization formats should be allowed.
$this->drupalGet($style_options);
$this->submitForm([
'style_options[formats][json]' => '0',
'style_options[formats][xml]' => '0',
], 'Apply');
// Ensure a request for JSON returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertSession()->statusCodeEquals(200);
// Ensure a request for XML returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'xml']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertSession()->statusCodeEquals(200);
// Should return a 406 for HTML still.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'html']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
}
/**
* Tests the field ID alias functionality of the DataFieldRow plugin.
*/
public function testUIFieldAlias(): void {
$this->drupalLogin($this->adminUser);
// Test the UI settings for adding field ID aliases.
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$row_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options';
$this->assertSession()->linkByHrefExists($row_options);
// Test an empty string for an alias, this should not be used. This also
// tests that the form can be submitted with no aliases.
$this->drupalGet($row_options);
$this->submitForm(['row_options[field_options][name][alias]' => ''], 'Apply');
$this->submitForm([], 'Save');
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$expected = [];
foreach ($view->result as $row) {
$expected_row = [];
foreach ($view->field as $id => $field) {
$expected_row[$id] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertEquals($expected, Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])));
// Test a random aliases for fields, they should be replaced.
$alias_map = [
'name' => $this->randomMachineName(),
// Use # to produce an invalid character for the validation.
'nothing' => '#' . $this->randomMachineName(),
'created' => 'created',
];
$edit = ['row_options[field_options][name][alias]' => $alias_map['name'], 'row_options[field_options][nothing][alias]' => $alias_map['nothing']];
$this->drupalGet($row_options);
$this->submitForm($edit, 'Apply');
$this->assertSession()->pageTextContains('The machine-readable name must contain only letters, numbers, dashes and underscores.');
// Change the map alias value to a valid one.
$alias_map['nothing'] = $this->randomMachineName();
$edit = ['row_options[field_options][name][alias]' => $alias_map['name'], 'row_options[field_options][nothing][alias]' => $alias_map['nothing']];
$this->drupalGet($row_options);
$this->submitForm($edit, 'Apply');
$this->submitForm([], 'Save');
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$expected = [];
foreach ($view->result as $row) {
$expected_row = [];
foreach ($view->field as $id => $field) {
$expected_row[$alias_map[$id]] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertEquals($expected, Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])));
}
/**
* Tests the raw output options for row field rendering.
*/
public function testFieldRawOutput(): void {
$this->drupalLogin($this->adminUser);
// Test the UI settings for adding field ID aliases.
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$row_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options';
$this->assertSession()->linkByHrefExists($row_options);
// Test an empty string for an alias, this should not be used. This also
// tests that the form can be submitted with no aliases.
$values = [
'row_options[field_options][created][raw_output]' => '1',
'row_options[field_options][name][raw_output]' => '1',
];
$this->drupalGet($row_options);
$this->submitForm($values, 'Apply');
$this->submitForm([], 'Save');
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$storage = $this->container->get('entity_type.manager')->getStorage('entity_test');
// Update the name for each to include a script tag.
foreach ($storage->loadMultiple() as $entity_test) {
$name = $entity_test->name->value;
$entity_test->set('name', "<script>$name</script>");
$entity_test->save();
}
// Just test the raw 'created' value against each row.
foreach (Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])) as $index => $values) {
$this->assertSame($view->result[$index]->views_test_data_created, $values['created'], 'Expected raw created value found.');
$this->assertSame($view->result[$index]->views_test_data_name, $values['name'], 'Expected raw name value found.');
}
// Test result with an excluded field.
$view->setDisplay('rest_export_1');
$view->displayHandlers->get('rest_export_1')->overrideOption('fields', [
'name' => [
'id' => 'name',
'table' => 'views_test_data',
'field' => 'name',
'relationship' => 'none',
],
'created' => [
'id' => 'created',
'exclude' => TRUE,
'table' => 'views_test_data',
'field' => 'created',
'relationship' => 'none',
],
]);
$view->save();
$this->executeView($view);
foreach (Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])) as $index => $values) {
$this->assertTrue(!isset($values['created']), 'Excluded value not found.');
}
// Test that the excluded field is not shown in the row options.
$this->drupalGet('admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options');
$this->assertSession()->pageTextNotContains('created');
}
/**
* Tests the live preview output for json output.
*/
public function testLivePreview(): void {
// We set up a request so it looks like a request in the live preview.
$request = new Request();
$request->query->add([MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']);
$request->setSession(new Session(new MockArraySessionStorage()));
/** @var \Symfony\Component\HttpFoundation\RequestStack $request_stack */
$request_stack = \Drupal::service('request_stack');
$request_stack->push($request);
$view = Views::getView('test_serializer_display_entity');
$view->setDisplay('rest_export_1');
$this->executeView($view);
// Get the serializer service.
$serializer = $this->container->get('serializer');
$entities = [];
foreach ($view->result as $row) {
$entities[] = $row->_entity;
}
$expected = $serializer->serialize($entities, 'json');
$view->live_preview = TRUE;
$build = $view->preview();
$rendered_json = $build['#plain_text'];
$this->assertArrayNotHasKey('#markup', $build);
$this->assertSame($expected, $rendered_json, 'Ensure the previewed json is escaped.');
$view->destroy();
$expected = $serializer->serialize($entities, 'xml');
// Change the request format to xml.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', [
'type' => 'serializer',
'options' => [
'uses_fields' => FALSE,
'formats' => [
'xml' => 'xml',
],
],
]);
$this->executeView($view);
$build = $view->preview();
$rendered_xml = $build['#plain_text'];
$this->assertEquals($expected, $rendered_xml, 'Ensure we preview xml when we change the request format.');
}
/**
* Tests the views interface for REST export displays.
*/
public function testSerializerViewsUI(): void {
$this->drupalLogin($this->adminUser);
// Click the "Update preview button".
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$this->submitForm($edit = [], 'Update preview');
$this->assertSession()->statusCodeEquals(200);
// Check if we receive the expected result.
$result = $this->assertSession()->elementExists('xpath', '//div[@id="views-live-preview"]/pre');
$json_preview = $result->getText();
$this->assertSame($json_preview, $this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]), 'The expected JSON preview output was found.');
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
/**
* Tests the serializer style plugin.
*
* @group rest
* @group #slow
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\style\Serializer
* @see \Drupal\rest\Plugin\views\row\DataEntityRow
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class StyleSerializerTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* Modules to install.
*
* @var array
*/
protected static $modules = [
'views_ui',
'entity_test',
'rest_test_views',
'node',
'text',
'field',
'language',
'basic_auth',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_serializer_display_field', 'test_serializer_display_entity', 'test_serializer_display_entity_translated', 'test_serializer_node_display_field', 'test_serializer_node_exposed_filter', 'test_serializer_shared_path'];
/**
* A user with administrative privileges to look at test entity and configure views.
*/
protected $adminUser;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
$this->adminUser = $this->drupalCreateUser([
'administer views',
'administer entity_test content',
'access user profiles',
'view test entity',
]);
$this->enableViewsTestModule();
$this->renderer = \Drupal::service('renderer');
}
/**
* Checks that the auth options restricts access to a REST views display.
*/
public function testRestViewsAuthentication(): void {
// Assume the view is hidden behind a permission.
$this->drupalGet('test/serialize/auth_with_perm', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(401);
// Not even logging in would make it possible to see the view, because then
// we are denied based on authentication method (cookie).
$this->drupalLogin($this->adminUser);
$this->drupalGet('test/serialize/auth_with_perm', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(403);
$this->drupalLogout();
// But if we use the basic auth authentication strategy, we should be able
// to see the page.
$url = $this->buildUrl('test/serialize/auth_with_perm');
$response = \Drupal::httpClient()->get($url, [
'auth' => [$this->adminUser->getAccountName(), $this->adminUser->pass_raw],
'query' => [
'_format' => 'json',
],
]);
// Ensure that any changes to variables in the other thread are picked up.
$this->refreshVariables();
$this->assertSession()->statusCodeEquals(200);
}
/**
* Verifies REST export views work on the same path as a page display.
*/
public function testSharedPagePath(): void {
// Test with no format as well as html explicitly.
$this->drupalGet('test/serialize/shared');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->drupalGet('test/serialize/shared', ['query' => ['_format' => 'html']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->drupalGet('test/serialize/shared', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->drupalGet('test/serialize/shared', ['query' => ['_format' => 'xml']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
}
/**
* Verifies site maintenance mode functionality.
*/
public function testSiteMaintenance(): void {
$view = Views::getView('test_serializer_display_field');
$view->initDisplay();
$this->executeView($view);
// Set the site to maintenance mode.
$this->container->get('state')->set('system.maintenance_mode', TRUE);
$this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]);
// Verify that the endpoint is unavailable for anonymous users.
$this->assertSession()->statusCodeEquals(503);
}
/**
* Sets up a request on the request stack with a specified format.
*
* @param string $format
* The new request format.
*/
protected function addRequestWithFormat($format) {
$request = \Drupal::request();
$request = clone $request;
$request->setRequestFormat($format);
\Drupal::requestStack()->push($request);
}
/**
* Tests the "Grouped rows" functionality.
*/
public function testGroupRows(): void {
$this->drupalCreateContentType(['type' => 'page']);
// Create a text field with cardinality set to unlimited.
$field_name = 'field_group_rows';
$field_storage = FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => 'node',
'type' => 'string',
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
]);
$field_storage->save();
// Create an instance of the text field on the content type.
$field = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
]);
$field->save();
$grouped_field_values = ['a', 'b', 'c'];
$edit = [
'title' => $this->randomMachineName(),
$field_name => $grouped_field_values,
];
$this->drupalCreateNode($edit);
$view = Views::getView('test_serializer_node_display_field');
$view->setDisplay('rest_export_1');
// Override the view's fields to include the field_group_rows field, set the
// group_rows setting to true.
$fields = [
$field_name => [
'id' => $field_name,
'table' => 'node__' . $field_name,
'field' => $field_name,
'type' => 'string',
'group_rows' => TRUE,
],
];
$view->displayHandlers->get('default')->overrideOption('fields', $fields);
$build = $view->preview();
// Get the serializer service.
$serializer = $this->container->get('serializer');
// Check if the field_group_rows field is grouped.
$expected = [];
$expected[] = [$field_name => implode(', ', $grouped_field_values)];
$this->assertEquals($serializer->serialize($expected, 'json'), (string) $this->renderer->renderRoot($build));
// Set the group rows setting to false.
$view = Views::getView('test_serializer_node_display_field');
$view->setDisplay('rest_export_1');
$fields[$field_name]['group_rows'] = FALSE;
$view->displayHandlers->get('default')->overrideOption('fields', $fields);
$build = $view->preview();
// Check if the field_group_rows field is ungrouped and displayed per row.
$expected = [];
foreach ($grouped_field_values as $grouped_field_value) {
$expected[] = [$field_name => $grouped_field_value];
}
$this->assertEquals($serializer->serialize($expected, 'json'), (string) $this->renderer->renderRoot($build));
}
/**
* Tests the exposed filter works.
*
* There is an exposed filter on the title field which takes a title query
* parameter. This is set to filter nodes by those whose title starts with
* the value provided.
*/
public function testRestViewExposedFilter(): void {
$this->drupalCreateContentType(['type' => 'page']);
$node0 = $this->drupalCreateNode(['title' => 'Node 1']);
$node1 = $this->drupalCreateNode(['title' => 'Node 11']);
$node2 = $this->drupalCreateNode(['title' => 'Node 111']);
// Test that no filter brings back all three nodes.
$result = Json::decode($this->drupalGet('test/serialize/node-exposed-filter', ['query' => ['_format' => 'json']]));
$expected = [
0 => [
'nid' => $node0->id(),
'body' => (string) $node0->body->processed,
],
1 => [
'nid' => $node1->id(),
'body' => (string) $node1->body->processed,
],
2 => [
'nid' => $node2->id(),
'body' => (string) $node2->body->processed,
],
];
$this->assertSame($expected, $result, 'Querying a view with no exposed filter returns all nodes.');
// Test that title starts with 'Node 11' query finds 2 of the 3 nodes.
$result = Json::decode($this->drupalGet('test/serialize/node-exposed-filter', ['query' => ['_format' => 'json', 'title' => 'Node 11']]));
$expected = [
0 => [
'nid' => $node1->id(),
'body' => (string) $node1->body->processed,
],
1 => [
'nid' => $node2->id(),
'body' => (string) $node2->body->processed,
],
];
$cache_contexts = [
'languages:language_content',
'languages:language_interface',
'theme',
'request_format',
'user.node_grants:view',
'url',
];
$this->assertSame($expected, $result, 'Querying a view with a starts with exposed filter on the title returns nodes whose title starts with value provided.');
$this->assertCacheContexts($cache_contexts);
}
/**
* Tests multilingual entity rows.
*/
public function testMulEntityRows(): void {
// Create some languages.
ConfigurableLanguage::createFromLangcode('l1')->save();
ConfigurableLanguage::createFromLangcode('l2')->save();
// Create an entity with no translations.
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_mul');
$storage->create(['langcode' => 'l1', 'name' => 'mul-none'])->save();
// Create some entities with translations.
$entity = $storage->create(['langcode' => 'l1', 'name' => 'mul-l1-orig']);
$entity->save();
$entity->addTranslation('l2', ['name' => 'mul-l1-l2'])->save();
$entity = $storage->create(['langcode' => 'l2', 'name' => 'mul-l2-orig']);
$entity->save();
$entity->addTranslation('l1', ['name' => 'mul-l2-l1'])->save();
// Get the names of the output.
$json = $this->drupalGet('test/serialize/translated_entity', ['query' => ['_format' => 'json']]);
$decoded = $this->container->get('serializer')->decode($json, 'json');
$names = [];
foreach ($decoded as $item) {
$names[] = $item['name'][0]['value'];
}
sort($names);
// Check that the names are correct.
$expected = ['mul-l1-l2', 'mul-l1-orig', 'mul-l2-l1', 'mul-l2-orig', 'mul-none'];
$this->assertSame($expected, $names, 'The translated content was found in the JSON.');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
/**
* Trait for ResourceTestBase subclasses testing $format='xml'.
*/
trait XmlNormalizationQuirksTrait {
/**
* Applies the XML encoding quirks that remain after decoding.
*
* The XML encoding:
* - maps empty arrays to the empty string
* - maps single-item arrays to just that single item
* - restructures multiple-item arrays that lives in a single-item array
*
* @param array $normalization
* A normalization.
*
* @return array
* The updated normalization.
*
* @see \Symfony\Component\Serializer\Encoder\XmlEncoder
*/
protected function applyXmlDecodingQuirks(array $normalization) {
foreach ($normalization as $key => $value) {
if ($value === [] || $value === NULL) {
$normalization[$key] = '';
}
elseif (is_array($value)) {
// Collapse single-item numeric arrays to just the single item.
if (count($value) === 1 && is_numeric(array_keys($value)[0]) && is_scalar($value[0])) {
$value = $value[0];
}
// Restructure multiple-item arrays inside a single-item numeric array.
// @see \Symfony\Component\Serializer\Encoder\XmlEncoder::buildXml()
elseif (count($value) === 1 && is_numeric(array_keys($value)[0]) && is_array(reset($value))) {
$rewritten_value = [];
foreach ($value[0] as $child_key => $child_value) {
if (is_numeric(array_keys(reset($value))[0])) {
$rewritten_value[$child_key] = ['@key' => $child_key] + $child_value;
}
else {
$rewritten_value[$child_key] = $child_value;
}
}
$value = $rewritten_value;
}
// If the post-quirk value is still an array after the above, recurse.
if (is_array($value)) {
$value = $this->applyXmlDecodingQuirks($value);
}
// Store post-quirk value.
$normalization[$key] = $value;
}
}
return $normalization;
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\ConfigDependencies;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @coversDefaultClass \Drupal\rest\Entity\ConfigDependencies
*
* @group rest
*/
class ConfigDependenciesTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest', 'entity_test', 'serialization'];
/**
* @covers ::calculateDependencies
*
* @dataProvider providerBasicDependencies
*/
public function testCalculateDependencies(array $configuration): void {
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create($configuration);
$result = $config_dependencies->calculateDependencies($rest_config);
$this->assertEquals([
'module' => ['basic_auth', 'serialization'],
], $result);
}
/**
* @covers ::onDependencyRemoval
* @covers ::onDependencyRemovalForMethodGranularity
* @covers ::onDependencyRemovalForResourceGranularity
*
* @dataProvider providerBasicDependencies
*/
public function testOnDependencyRemovalRemoveUnrelatedDependency(array $configuration): void {
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create($configuration);
$this->assertFalse($config_dependencies->onDependencyRemoval($rest_config, ['module' => ['node']]));
$this->assertEquals($configuration['configuration'], $rest_config->get('configuration'));
}
/**
* @return array
* An array with numerical keys:
* 0. The original REST resource configuration.
*/
public static function providerBasicDependencies() {
return [
'method' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['basic_auth'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['xml'],
],
],
],
],
'resource' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['json'],
'authentication' => ['cookie', 'basic_auth'],
],
],
],
];
}
/**
* @covers ::onDependencyRemoval
* @covers ::onDependencyRemovalForMethodGranularity
*/
public function testOnDependencyRemovalRemoveAuth(): void {
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create([
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_auth' => ['basic_auth'],
'supported_formats' => ['json'],
],
],
]);
$this->assertTrue($config_dependencies->onDependencyRemoval($rest_config, ['module' => ['basic_auth']]));
$this->assertEquals(['cookie'], $rest_config->getAuthenticationProviders('GET'));
$this->assertEquals([], $rest_config->getAuthenticationProviders('POST'));
$this->assertEquals([
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_formats' => ['json'],
],
], $rest_config->get('configuration'));
}
/**
* @covers ::onDependencyRemoval
* @covers ::onDependencyRemovalForResourceGranularity
*
* @dataProvider providerOnDependencyRemovalForResourceGranularity
*/
public function testOnDependencyRemovalForResourceGranularity(array $configuration, $module, $expected_configuration): void {
assert(is_string($module));
assert($expected_configuration === FALSE || is_array($expected_configuration));
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create($configuration);
$this->assertSame(!empty($expected_configuration), $config_dependencies->onDependencyRemoval($rest_config, ['module' => [$module]]));
if (!empty($expected_configuration)) {
$this->assertEquals($expected_configuration, $rest_config->get('configuration'));
}
}
/**
* @return array
* An array with numerical keys:
* 0. The original REST resource configuration.
* 1. The module to uninstall (the dependency that is about to be removed).
* 2. The expected configuration after uninstalling this module.
*/
public static function providerOnDependencyRemovalForResourceGranularity() {
return [
'resource with multiple formats' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['xml', 'json'],
'authentication' => ['cookie', 'basic_auth'],
],
],
'serialization',
[
'methods' => ['GET', 'POST'],
'formats' => ['xml'],
'authentication' => ['cookie', 'basic_auth'],
],
],
'resource with multiple authentication providers' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['json', 'xml'],
'authentication' => ['cookie', 'basic_auth'],
],
],
'basic_auth',
[
'methods' => ['GET', 'POST'],
'formats' => ['json', 'xml'],
'authentication' => ['cookie'],
],
],
'resource with only basic_auth authentication' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['json', 'xml'],
'authentication' => ['basic_auth'],
],
],
'basic_auth',
FALSE,
],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @coversDefaultClass \Drupal\rest\RestPermissions
*
* @group rest
*/
class RestPermissionsTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'rest',
'dblog',
'serialization',
'basic_auth',
'user',
];
/**
* @covers ::permissions
*/
public function testPermissions(): void {
RestResourceConfig::create([
'id' => 'dblog',
'plugin_id' => 'dblog',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
],
])->save();
$permissions = $this->container->get('user.permissions')->getPermissions();
$this->assertArrayHasKey('restful get dblog', $permissions);
$this->assertSame(['config' => ['rest.resource.dblog']], $permissions['restful get dblog']['dependencies']);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @coversDefaultClass \Drupal\rest\Entity\RestResourceConfig
*
* @group rest
*/
class RestResourceConfigTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'rest',
'entity_test',
'serialization',
'basic_auth',
'user',
];
/**
* @covers ::calculateDependencies
*/
public function testCalculateDependencies(): void {
$rest_config = RestResourceConfig::create([
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_auth' => ['basic_auth'],
'supported_formats' => ['json'],
],
],
]);
$rest_config->calculateDependencies();
$this->assertEquals(['module' => ['basic_auth', 'entity_test', 'serialization', 'user']], $rest_config->getDependencies());
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* Tests validation of rest_resource_config entities.
*
* @group rest
*/
class RestResourceConfigValidationTest extends ConfigEntityValidationTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest', 'serialization'];
/**
* {@inheritdoc}
*/
protected bool $hasLabel = FALSE;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->entity = RestResourceConfig::create([
'id' => 'test',
'plugin_id' => 'entity:date_format',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [],
]);
$this->entity->save();
}
/**
* Tests that the resource plugin ID is validated.
*/
public function testInvalidPluginId(): void {
$this->entity->set('plugin_id', 'non_existent');
$this->assertValidationErrors([
'plugin_id' => "The 'non_existent' plugin does not exist.",
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\EntityResource;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Extension\ExtensionLifecycle;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
/**
* Checks that all core content/config entity types have REST test coverage.
*
* Every entity type must have test coverage for:
* - every format in core (json + xml)
* - every authentication provider in core (anon, cookie, basic_auth)
*
* Additionally, every entity type must have the correct parent test class.
*
* @group rest
*/
class EntityResourceRestTestCoverageTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['system', 'user'];
/**
* Entity definitions array.
*
* @var array
*/
protected $definitions;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$all_modules = $this->container->get('extension.list.module')->getList();
$stable_core_modules = array_filter($all_modules, function ($module) {
// Filter out contrib, hidden, testing, deprecated and experimental
// modules. We also don't need to enable modules that are already enabled.
return $module->origin === 'core' &&
empty($module->info['hidden']) &&
$module->status == FALSE &&
$module->info['package'] !== 'Testing' &&
$module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] !== ExtensionLifecycle::DEPRECATED &&
$module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] !== ExtensionLifecycle::EXPERIMENTAL;
});
$this->container->get('module_installer')->install(array_keys($stable_core_modules));
$this->definitions = $this->container->get('entity_type.manager')->getDefinitions();
// Entity types marked as "internal" are not exposed by the entity REST
// resource plugin and hence also don't need test coverage.
$this->definitions = array_filter($this->definitions, function (EntityTypeInterface $entity_type) {
return !$entity_type->isInternal();
});
}
/**
* Tests that all core content/config entity types have REST test coverage.
*/
public function testEntityTypeRestTestCoverage(): void {
$tests = [
// Test coverage for formats provided by the 'serialization' module.
'serialization' => [
'path' => '\Drupal\Tests\PROVIDER\Functional\Rest\CLASS',
'class suffix' => [
'JsonAnonTest',
'JsonBasicAuthTest',
'JsonCookieTest',
'XmlAnonTest',
'XmlBasicAuthTest',
'XmlCookieTest',
],
],
];
$problems = [];
foreach ($this->definitions as $entity_type_id => $info) {
$class_name_full = $info->getClass();
$parts = explode('\\', $class_name_full);
$class_name = end($parts);
$module_name = $parts[1];
foreach ($tests as $module => $info) {
$path = $info['path'];
$missing_tests = [];
foreach ($info['class suffix'] as $postfix) {
$class = str_replace(['PROVIDER', 'CLASS'], [$module_name, $class_name], $path . $postfix);
$class_alternative = str_replace("\\Drupal\\Tests\\$module_name\\Functional", '\Drupal\FunctionalTests', $class);
// For entities defined in the system module with Jsonapi tests in
// another module.
$class_entity_in_system_alternative = str_replace(['PROVIDER', 'CLASS'], [$entity_type_id, $class_name], $path . $postfix);
if (class_exists($class) || class_exists($class_alternative) || class_exists($class_entity_in_system_alternative)) {
continue;
}
$missing_tests[] = $postfix;
}
if (!empty($missing_tests)) {
$missing_tests_list = implode(', ', array_map(function ($missing_test) use ($class_name) {
return $class_name . $missing_test;
}, $missing_tests));
$which_normalization = $module === 'serialization' ? 'default' : $module;
$problems[] = "$entity_type_id: $class_name ($class_name_full), $which_normalization normalization (expected tests: $missing_tests_list)";
}
}
$config_entity = is_subclass_of($class_name_full, ConfigEntityInterface::class);
$config_test = is_subclass_of($class, ConfigEntityResourceTestBase::class)
|| is_subclass_of($class_alternative, ConfigEntityResourceTestBase::class)
|| is_subclass_of($class_entity_in_system_alternative, ConfigEntityResourceTestBase::class);
if ($config_entity && !$config_test) {
$problems[] = "$entity_type_id: $class_name is a config entity, but the test is for content entities.";
}
elseif (!$config_entity && $config_test) {
$problems[] = "$entity_type_id: $class_name is a content entity, but the test is for config entities.";
}
}
$this->assertSame([], $problems);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Routing\RouteMatch;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\RequestHandler;
use Drupal\rest\ResourceResponse;
use Drupal\rest\RestResourceConfigInterface;
use Prophecy\Argument;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Test REST RequestHandler controller logic.
*
* @group rest
* @coversDefaultClass \Drupal\rest\RequestHandler
*/
class RequestHandlerTest extends KernelTestBase {
/**
* @var \Drupal\rest\RequestHandler
*/
protected $requestHandler;
protected static $modules = ['serialization', 'rest'];
/**
* The entity storage.
*
* @var \Prophecy\Prophecy\ObjectProphecy
*/
protected $entityStorage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$serializer = $this->prophesize(SerializerInterface::class);
$serializer->willImplement(DecoderInterface::class);
$serializer->decode(Json::encode(['this is an array']), 'json', Argument::type('array'))
->willReturn(['this is an array']);
$this->requestHandler = new RequestHandler($serializer->reveal());
}
/**
* @covers ::handle
*/
public function testHandle(): void {
$request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/json'], Json::encode(['this is an array']));
$route_match = new RouteMatch('test', (new Route('/rest/test', ['_rest_resource_config' => 'rest_plugin', 'example' => ''], ['_format' => 'json']))->setMethods(['GET']));
$resource = $this->prophesize(StubRequestHandlerResourcePlugin::class);
$resource->get('', $request)
->shouldBeCalled();
$resource->getPluginDefinition()
->willReturn([])
->shouldBeCalled();
// Setup the configuration.
$config = $this->prophesize(RestResourceConfigInterface::class);
$config->getResourcePlugin()->willReturn($resource->reveal());
$config->getCacheContexts()->willReturn([]);
$config->getCacheTags()->willReturn([]);
$config->getCacheMaxAge()->willReturn(12);
// Response returns NULL this time because response from plugin is not
// a ResourceResponse so it is passed through directly.
$response = $this->requestHandler->handle($route_match, $request, $config->reveal());
$this->assertEquals(NULL, $response);
// Response will return a ResourceResponse this time.
$response = new ResourceResponse([]);
$resource->get(NULL, $request)
->willReturn($response);
$handler_response = $this->requestHandler->handle($route_match, $request, $config->reveal());
$this->assertEquals($response, $handler_response);
// We will call the patch method this time.
$route_match = new RouteMatch('test', (new Route('/rest/test', ['_rest_resource_config' => 'rest_plugin', 'example_original' => ''], ['_content_type_format' => 'json']))->setMethods(['PATCH']));
$request->setMethod('PATCH');
$response = new ResourceResponse([]);
$resource->patch(['this is an array'], $request)
->shouldBeCalledTimes(1)
->willReturn($response);
$handler_response = $this->requestHandler->handle($route_match, $request, $config->reveal());
$this->assertEquals($response, $handler_response);
}
}
/**
* Stub class where we can prophesize methods.
*/
class StubRequestHandlerResourcePlugin extends ResourceBase {
public function get($example = NULL, ?Request $request = NULL) {}
public function post() {}
public function patch($data, Request $request) {}
public function delete() {}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Views;
use Drupal\rest\Plugin\views\display\RestExport;
use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
use Drupal\views\Entity\View;
use Drupal\views\Tests\ViewTestData;
/**
* Tests the REST export view display plugin.
*
* @coversDefaultClass \Drupal\rest\Plugin\views\display\RestExport
*
* @group rest
*/
class RestExportTest extends ViewsKernelTestBase {
/**
* {@inheritdoc}
*/
public static $testViews = ['test_serializer_display_entity'];
/**
* {@inheritdoc}
*/
protected static $modules = [
'rest_test_views',
'serialization',
'rest',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE): void {
parent::setUp($import_test_views);
ViewTestData::createTestViews(static::class, ['rest_test_views']);
$this->installEntitySchema('entity_test');
}
/**
* @covers ::buildResponse
*/
public function testBuildResponse(): void {
/** @var \Drupal\views\Entity\View $view */
$view = View::load('test_serializer_display_entity');
$display = &$view->getDisplay('rest_export_1');
$display['display_options']['defaults']['style'] = FALSE;
$display['display_options']['style']['type'] = 'serializer';
$display['display_options']['style']['options']['formats'] = ['json', 'xml'];
$view->save();
// No custom header should be set yet.
$response = RestExport::buildResponse('test_serializer_display_entity', 'rest_export_1', []);
$this->assertEmpty($response->headers->get('Custom-Header'));
// Clear render cache.
/** @var \Drupal\Core\Cache\MemoryBackend $render_cache */
$render_cache = $this->container->get('cache_factory')->get('render');
$render_cache->deleteAll();
// A custom header should now be added.
// @see rest_test_views_views_post_execute()
$header = $this->randomString();
$this->container->get('state')->set('rest_test_views_set_header', $header);
$response = RestExport::buildResponse('test_serializer_display_entity', 'rest_export_1', []);
$this->assertEquals($header, $response->headers->get('Custom-Header'));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Views;
use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
use Drupal\views\Entity\View;
use Drupal\views\Tests\ViewTestData;
/**
* @coversDefaultClass \Drupal\rest\Plugin\views\style\Serializer
* @group views
*/
class StyleSerializerKernelTest extends ViewsKernelTestBase {
/**
* {@inheritdoc}
*/
public static $testViews = ['test_serializer_display_entity'];
/**
* {@inheritdoc}
*/
protected static $modules = ['rest_test_views', 'serialization', 'rest'];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE): void {
parent::setUp($import_test_views);
ViewTestData::createTestViews(static::class, ['rest_test_views']);
}
/**
* @covers ::calculateDependencies
*/
public function testCalculateDependencies(): void {
/** @var \Drupal\views\Entity\View $view */
$view = View::load('test_serializer_display_entity');
$display = &$view->getDisplay('rest_export_1');
$display['display_options']['defaults']['style'] = FALSE;
$display['display_options']['style']['type'] = 'serializer';
$display['display_options']['style']['options']['formats'] = ['json', 'xml'];
$view->save();
$view->calculateDependencies();
$this->assertEquals(['module' => ['rest', 'serialization', 'user']], $view->getDependencies());
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\rest\Plugin\views\display\RestExport;
use Drupal\views\Entity\View;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Tests the REST export view plugin.
*
* @group rest
*/
class CollectRoutesTest extends UnitTestCase {
/**
* The REST export instance.
*
* @var \Drupal\rest\Plugin\views\display\RestExport
*/
protected $restExport;
/**
* The RouteCollection.
*/
protected $routes;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$container = new ContainerBuilder();
$request = $this->getMockBuilder('\Symfony\Component\HttpFoundation\Request')
->disableOriginalConstructor()
->getMock();
$view = new View(['id' => 'test_view'], 'view');
$view_executable = $this->getMockBuilder('\Drupal\views\ViewExecutable')
->onlyMethods(['initHandlers', 'getTitle'])
->disableOriginalConstructor()
->getMock();
$view_executable->expects($this->any())
->method('getTitle')
->willReturn('View title');
$view_executable->storage = $view;
$view_executable->argument = [];
$display_manager = $this->getMockBuilder('\Drupal\views\Plugin\ViewsPluginManager')
->disableOriginalConstructor()
->getMock();
$container->set('plugin.manager.views.display', $display_manager);
$access_manager = $this->getMockBuilder('\Drupal\views\Plugin\ViewsPluginManager')
->disableOriginalConstructor()
->getMock();
$container->set('plugin.manager.views.access', $access_manager);
$route_provider = $this->getMockBuilder('\Drupal\Core\Routing\RouteProviderInterface')
->disableOriginalConstructor()
->getMock();
$container->set('router.route_provider', $route_provider);
$container->setParameter('authentication_providers', ['basic_auth' => 'basic_auth']);
$state = $this->createMock('\Drupal\Core\State\StateInterface');
$container->set('state', $state);
$style_manager = $this->getMockBuilder('\Drupal\views\Plugin\ViewsPluginManager')
->disableOriginalConstructor()
->getMock();
$container->set('plugin.manager.views.style', $style_manager);
$container->set('renderer', $this->createMock('Drupal\Core\Render\RendererInterface'));
$authentication_collector = $this->createMock('\Drupal\Core\Authentication\AuthenticationCollectorInterface');
$container->set('authentication_collector', $authentication_collector);
$authentication_collector->expects($this->any())
->method('getSortedProviders')
->willReturn(['basic_auth' => 'data', 'cookie' => 'data']);
$container->setParameter('serializer.format_providers', ['json']);
\Drupal::setContainer($container);
$this->restExport = RestExport::create($container, [], "test_routes", []);
$this->restExport->view = $view_executable;
// Initialize a display.
$this->restExport->display = ['id' => 'page_1'];
// Set the style option.
$this->restExport->setOption('style', ['type' => 'serializer']);
// Set the auth option.
$this->restExport->setOption('auth', ['basic_auth']);
$display_manager->expects($this->once())
->method('getDefinition')
->willReturn(['id' => 'test', 'provider' => 'test']);
$none = $this->getMockBuilder('\Drupal\views\Plugin\views\access\None')
->disableOriginalConstructor()
->getMock();
$access_manager->expects($this->once())
->method('createInstance')
->willReturn($none);
$style_plugin = $this->getMockBuilder('\Drupal\rest\Plugin\views\style\Serializer')
->onlyMethods(['getFormats', 'init'])
->disableOriginalConstructor()
->getMock();
$style_plugin->expects($this->once())
->method('getFormats')
->willReturn(['json']);
$style_plugin->expects($this->once())
->method('init')
->with($view_executable)
->willReturn(TRUE);
$style_manager->expects($this->once())
->method('createInstance')
->willReturn($style_plugin);
$this->routes = new RouteCollection();
$this->routes->add('test_1', new Route('/test/1'));
$this->routes->add('view.test_view.page_1', new Route('/test/2'));
$view->addDisplay('page', NULL, 'page_1');
}
/**
* Tests if adding a requirement to a route only modify one route.
*/
public function testRoutesRequirements(): void {
$this->restExport->collectRoutes($this->routes);
$requirements_1 = $this->routes->get('test_1')->getRequirements();
$requirements_2 = $this->routes->get('view.test_view.page_1')->getRequirements();
$this->assertCount(0, $requirements_1, 'First route has no requirement.');
$this->assertCount(1, $requirements_2, 'Views route with rest export had the format requirement added.');
// Check auth options.
$auth = $this->routes->get('view.test_view.page_1')->getOption('_auth');
$this->assertCount(1, $auth, 'View route with rest export has an auth option added');
$this->assertEquals('basic_auth', $auth[0], 'View route with rest export has the correct auth option added');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit\Entity;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\rest\Entity\RestResourceConfig
*
* @group rest
*/
class RestResourceConfigTest extends UnitTestCase {
/**
* Asserts that rest methods are normalized to upper case.
*
* This also tests that no exceptions are thrown during that method so that
* alternate methods such as OPTIONS and PUT are supported.
*/
public function testNormalizeRestMethod(): void {
$expected = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'FOO'];
$methods = ['get', 'put', 'post', 'patch', 'delete', 'options', 'foo'];
$configuration = [];
foreach ($methods as $method) {
$configuration[$method] = [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
];
}
$entity = new RestResourceConfig([
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => $configuration,
], 'rest_resource_config');
$this->assertEquals($expected, $entity->getMethods());
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit;
use Drupal\Core\Entity\EntityConstraintViolationList;
use Drupal\node\Entity\Node;
use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait;
use Drupal\Tests\UnitTestCase;
use Drupal\user\Entity\User;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* @group rest
* @coversDefaultClass \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait
*/
class EntityResourceValidationTraitTest extends UnitTestCase {
/**
* @covers ::validate
*/
public function testValidate(): void {
$trait = new EntityResourceValidationTraitTestClass();
$method = new \ReflectionMethod($trait, 'validate');
$violations = $this->prophesize(EntityConstraintViolationList::class);
$violations->filterByFieldAccess()->shouldBeCalled()->willReturn([]);
$violations->count()->shouldBeCalled()->willReturn(0);
$entity = $this->prophesize(Node::class);
$entity->validate()->shouldBeCalled()->willReturn($violations->reveal());
$method->invoke($trait, $entity->reveal());
}
/**
* @covers ::validate
*/
public function testFailedValidate(): void {
$violation1 = $this->prophesize(ConstraintViolationInterface::class);
$violation1->getPropertyPath()->willReturn('property_path');
$violation1->getMessage()->willReturn('message');
$violation2 = $this->prophesize(ConstraintViolationInterface::class);
$violation2->getPropertyPath()->willReturn('property_path');
$violation2->getMessage()->willReturn('message');
$entity = $this->prophesize(User::class);
$violations = $this->getMockBuilder(EntityConstraintViolationList::class)
->setConstructorArgs([$entity->reveal(), [$violation1->reveal(), $violation2->reveal()]])
->onlyMethods(['filterByFieldAccess'])
->getMock();
$violations->expects($this->once())
->method('filterByFieldAccess')
->willReturn([]);
$entity->validate()->willReturn($violations);
$trait = new EntityResourceValidationTraitTestClass();
$method = new \ReflectionMethod($trait, 'validate');
$this->expectException(UnprocessableEntityHttpException::class);
$method->invoke($trait, $entity->reveal());
}
}
/**
* A test class to use to test EntityResourceValidationTrait.
*
* Because the mock doesn't use the \Drupal namespace, the Symfony 4+ class
* loader will throw a deprecation error.
*/
class EntityResourceValidationTraitTestClass {
use EntityResourceValidationTrait;
}

View File

@@ -0,0 +1,424 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit\EventSubscriber;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\rest\EventSubscriber\ResourceResponseSubscriber;
use Drupal\rest\ModifiedResourceResponse;
use Drupal\rest\ResourceResponse;
use Drupal\rest\ResourceResponseInterface;
use Drupal\serialization\Encoder\JsonEncoder;
use Drupal\serialization\Encoder\XmlEncoder;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @coversDefaultClass \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
* @group rest
*/
class ResourceResponseSubscriberTest extends UnitTestCase {
/**
* @covers ::onResponse
* @dataProvider providerTestSerialization
*/
public function testSerialization($data, $expected_response = FALSE): void {
$request = new Request();
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => 'rest_plugin'], ['_format' => 'json']));
$handler_response = new ResourceResponse($data);
$resource_response_subscriber = $this->getFunctioningResourceResponseSubscriber($route_match);
$event = new ResponseEvent(
$this->prophesize(HttpKernelInterface::class)->reveal(),
$request,
HttpKernelInterface::MAIN_REQUEST,
$handler_response
);
$resource_response_subscriber->onResponse($event);
// Content is a serialized version of the data we provided.
$this->assertEquals($expected_response !== FALSE ? $expected_response : Json::encode($data), $event->getResponse()->getContent());
}
public static function providerTestSerialization() {
return [
// The default data for \Drupal\rest\ResourceResponse.
'default' => [NULL, ''],
'empty string' => [''],
'simple string' => ['string'],
// cSpell:disable-next-line
'complex string' => ['Complex \ string $%^&@ with unicode ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΣὨ'],
'empty array' => [[]],
'numeric array' => [['test']],
'associative array' => [['test' => 'foobar']],
'boolean true' => [TRUE],
'boolean false' => [FALSE],
];
}
/**
* @covers ::getResponseFormat
*
* Note this does *not* need to test formats being requested that are not
* accepted by the server, because the routing system would have already
* prevented those from reaching the controller.
*
* @dataProvider providerTestResponseFormat
*/
public function testResponseFormat($methods, array $supported_response_formats, array $supported_request_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content): void {
foreach ($request_headers as $key => $value) {
unset($request_headers[$key]);
$key = strtoupper(str_replace('-', '_', $key));
$request_headers[$key] = $value;
}
foreach ($methods as $method) {
$request = Request::create('/rest/test', $method, [], [], [], $request_headers, $request_body);
// \Drupal\Core\StackMiddleware\NegotiationMiddleware normally takes care
// of this so we'll hard code it here.
if ($request_format) {
$request->setRequestFormat($request_format);
}
$route_requirements = $this->generateRouteRequirements($supported_response_formats, $supported_request_formats);
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements));
$resource_response_subscriber = new ResourceResponseSubscriber(
$this->prophesize(SerializerInterface::class)->reveal(),
$this->prophesize(RendererInterface::class)->reveal(),
$route_match
);
$this->assertSame($expected_response_format, $resource_response_subscriber->getResponseFormat($route_match, $request));
}
}
/**
* @covers ::onResponse
* @covers ::getResponseFormat
* @covers ::renderResponseBody
* @covers ::flattenResponse
*
* @dataProvider providerTestResponseFormat
*/
public function testOnResponseWithCacheableResponse($methods, array $supported_response_formats, array $supported_request_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content): void {
foreach ($request_headers as $key => $value) {
unset($request_headers[$key]);
$key = strtoupper(str_replace('-', '_', $key));
$request_headers[$key] = $value;
}
foreach ($methods as $method) {
$request = Request::create('/rest/test', $method, [], [], [], $request_headers, $request_body);
// \Drupal\Core\StackMiddleware\NegotiationMiddleware normally takes care
// of this so we'll hard code it here.
if ($request_format) {
$request->setRequestFormat($request_format);
}
$route_requirements = $this->generateRouteRequirements($supported_response_formats, $supported_request_formats);
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements));
// The RequestHandler must return a ResourceResponseInterface object.
$handler_response = new ResourceResponse(['REST' => 'Drupal']);
$this->assertInstanceOf(ResourceResponseInterface::class, $handler_response);
$this->assertInstanceOf(CacheableResponseInterface::class, $handler_response);
// The ResourceResponseSubscriber must then generate a response body and
// transform it to a plain CacheableResponse object.
$resource_response_subscriber = $this->getFunctioningResourceResponseSubscriber($route_match);
$event = new ResponseEvent(
$this->prophesize(HttpKernelInterface::class)->reveal(),
$request,
HttpKernelInterface::MAIN_REQUEST,
$handler_response
);
$resource_response_subscriber->onResponse($event);
$final_response = $event->getResponse();
$this->assertNotInstanceOf(ResourceResponseInterface::class, $final_response);
$this->assertInstanceOf(CacheableResponseInterface::class, $final_response);
$this->assertSame($expected_response_content_type, $final_response->headers->get('Content-Type'));
$this->assertEquals($expected_response_content, $final_response->getContent());
}
}
/**
* @covers ::onResponse
* @covers ::getResponseFormat
* @covers ::renderResponseBody
* @covers ::flattenResponse
*
* @dataProvider providerTestResponseFormat
*/
public function testOnResponseWithUncacheableResponse($methods, array $supported_response_formats, array $supported_request_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content): void {
foreach ($request_headers as $key => $value) {
unset($request_headers[$key]);
$key = strtoupper(str_replace('-', '_', $key));
$request_headers[$key] = $value;
}
foreach ($methods as $method) {
$request = Request::create('/rest/test', $method, [], [], [], $request_headers, $request_body);
// \Drupal\Core\StackMiddleware\NegotiationMiddleware normally takes care
// of this so we'll hard code it here.
if ($request_format) {
$request->setRequestFormat($request_format);
}
$route_requirements = $this->generateRouteRequirements($supported_response_formats, $supported_request_formats);
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements));
// The RequestHandler must return a ResourceResponseInterface object.
$handler_response = new ModifiedResourceResponse(['REST' => 'Drupal']);
$this->assertInstanceOf(ResourceResponseInterface::class, $handler_response);
$this->assertNotInstanceOf(CacheableResponseInterface::class, $handler_response);
// The ResourceResponseSubscriber must then generate a response body and
// transform it to a plain Response object.
$resource_response_subscriber = $this->getFunctioningResourceResponseSubscriber($route_match);
$event = new ResponseEvent(
$this->prophesize(HttpKernelInterface::class)->reveal(),
$request,
HttpKernelInterface::MAIN_REQUEST,
$handler_response
);
$resource_response_subscriber->onResponse($event);
$final_response = $event->getResponse();
$this->assertNotInstanceOf(ResourceResponseInterface::class, $final_response);
$this->assertNotInstanceOf(CacheableResponseInterface::class, $final_response);
$this->assertSame($expected_response_content_type, $final_response->headers->get('Content-Type'));
$this->assertEquals($expected_response_content, $final_response->getContent());
}
}
/**
* @return array
* 0. methods to test
* 1. supported formats for route requirements
* 2. request format
* 3. request headers
* 4. request body
* 5. expected response format
* 6. expected response content type
* 7. expected response body
*/
public static function providerTestResponseFormat() {
$json_encoded = Json::encode(['REST' => 'Drupal']);
$xml_encoded = "<?xml version=\"1.0\"?>\n<response><REST>Drupal</REST></response>\n";
$safe_method_test_cases = [
'safe methods: client requested format (JSON)' => [
['GET', 'HEAD'],
['xml', 'json'],
[],
'json',
[],
NULL,
'json',
'application/json',
$json_encoded,
],
'safe methods: client requested format (XML)' => [
['GET', 'HEAD'],
['xml', 'json'],
[],
'xml',
[],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
'safe methods: client requested no format: response should use the first configured format (JSON)' => [
['GET', 'HEAD'],
['json', 'xml'],
[],
FALSE,
[],
NULL,
'json',
'application/json',
$json_encoded,
],
'safe methods: client requested no format: response should use the first configured format (XML)' => [
['GET', 'HEAD'],
['xml', 'json'],
[],
FALSE,
[],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
];
$unsafe_method_bodied_test_cases = [
'unsafe methods with response (POST, PATCH): client requested no format, response should use request body format (JSON)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
FALSE,
['Content-Type' => 'application/json'],
$json_encoded,
'json',
'application/json',
$json_encoded,
],
'unsafe methods with response (POST, PATCH): client requested no format, response should use request body format (XML)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
FALSE,
['Content-Type' => 'text/xml'],
$xml_encoded,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format (JSON): response format should use requested format (XML)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
'xml',
['Content-Type' => 'application/json'],
$json_encoded,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format (XML), but is allowed for the request body (JSON)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
'json',
['Content-Type' => 'text/xml'],
$xml_encoded,
'json',
'application/json',
$json_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format when only XML is allowed as a content type format' => [
['POST', 'PATCH'],
['xml'],
['json'],
'json',
['Content-Type' => 'text/xml'],
$xml_encoded,
'json',
'application/json',
$json_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format when only JSON is allowed as a content type format' => [
['POST', 'PATCH'],
['json'],
['xml'],
'xml',
['Content-Type' => 'application/json'],
$json_encoded,
'xml',
'text/xml',
$xml_encoded,
],
];
$unsafe_method_bodyless_test_cases = [
'unsafe methods without request bodies (DELETE): client requested no format, response should have the first acceptable format' => [
['DELETE'],
['xml', 'json'],
['xml', 'json'],
FALSE,
['Content-Type' => 'application/json'],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods without request bodies (DELETE): client requested format (XML), response should have xml format' => [
['DELETE'],
['xml', 'json'],
['xml', 'json'],
'xml',
['Content-Type' => 'application/json'],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods without request bodies (DELETE): client requested format (JSON), response should have json format' => [
['DELETE'],
['xml', 'json'],
['xml', 'json'],
'json',
['Content-Type' => 'application/json'],
NULL,
'json',
'application/json',
$json_encoded,
],
];
return $safe_method_test_cases + $unsafe_method_bodied_test_cases + $unsafe_method_bodyless_test_cases;
}
/**
* @return \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
*/
protected function getFunctioningResourceResponseSubscriber(RouteMatchInterface $route_match) {
// Create a dummy of the renderer service.
$renderer = $this->prophesize(RendererInterface::class);
$renderer->executeInRenderContext(Argument::type(RenderContext::class), Argument::type('callable'))
->will(function ($args) {
$callable = $args[1];
return $callable();
});
// Instantiate the ResourceResponseSubscriber we will test.
$resource_response_subscriber = new ResourceResponseSubscriber(
new Serializer([], [new JsonEncoder(), new XmlEncoder()]),
$renderer->reveal(),
$route_match
);
return $resource_response_subscriber;
}
/**
* Generates route requirements based on supported formats.
*
* @param array $supported_response_formats
* The supported response formats to add to the route requirements.
* @param array $supported_request_formats
* The supported request formats to add to the route requirements.
*
* @return array
* An array of route requirements.
*/
protected function generateRouteRequirements(array $supported_response_formats, array $supported_request_formats) {
$route_requirements = [
'_format' => implode('|', $supported_response_formats),
];
if (!empty($supported_request_formats)) {
$route_requirements['_content_type_format'] = implode('|', $supported_request_formats);
}
return $route_requirements;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit\Plugin\views\style;
use Drupal\rest\Plugin\views\display\RestExport;
use Drupal\rest\Plugin\views\style\Serializer;
use Drupal\Tests\UnitTestCase;
use Drupal\views\ViewExecutable;
use Prophecy\Argument;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @coversDefaultClass \Drupal\rest\Plugin\views\style\Serializer
* @group rest
*/
class SerializerTest extends UnitTestCase {
/**
* The View instance.
*
* @var \Drupal\views\ViewExecutable|\PHPUnit\Framework\MockObject\MockObject
*/
protected $view;
/**
* The RestExport display handler.
*
* @var \Drupal\rest\Plugin\views\display\RestExport|\PHPUnit\Framework\MockObject\MockObject
*/
protected $displayHandler;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->view = $this->getMockBuilder(ViewExecutable::class)
->disableOriginalConstructor()
->getMock();
// Make the view result empty so we don't have to mock the row plugin render
// call.
$this->view->result = [];
$this->displayHandler = $this->getMockBuilder(RestExport::class)
->disableOriginalConstructor()
->getMock();
$this->displayHandler->expects($this->any())
->method('getContentType')
->willReturn('json');
}
/**
* Tests that the symfony serializer receives style plugin from the render() method.
*
* @covers ::render
*/
public function testSerializerReceivesOptions(): void {
$mock_serializer = $this->prophesize(SerializerInterface::class);
// This is the main expectation of the test. We want to make sure the
// serializer options are passed to the SerializerInterface object.
$mock_serializer->serialize([], 'json', Argument::that(function ($argument) {
return isset($argument['views_style_plugin']) && $argument['views_style_plugin'] instanceof Serializer;
}))
->willReturn('')
->shouldBeCalled();
$view_serializer_style = new Serializer([], 'dummy_serializer', [], $mock_serializer->reveal(), ['json', 'xml'], ['json' => 'serialization', 'xml' => 'serialization']);
$view_serializer_style->options = ['formats' => ['xml', 'json']];
$view_serializer_style->view = $this->view;
$view_serializer_style->displayHandler = $this->displayHandler;
$view_serializer_style->render();
}
}