first commit

This commit is contained in:
2020-06-08 23:57:36 +02:00
commit 6277454f7a
16057 changed files with 1715382 additions and 0 deletions
@@ -0,0 +1,49 @@
<?php
/**
* @file
* Contains database additions for testing jsonapi_update_8701()'s update path.
*
* @depends core/modules/system/tests/fixtures/update/drupal-8.bare.standard.php.gz
*/
use Drupal\Core\Database\Database;
$connection = Database::getConnection();
// Set the schema version.
$connection->insert('key_value')
->fields([
'collection',
'name',
'value',
])
->values([
'collection' => 'system.schema',
'name' => 'serialization',
'value' => 'i:8401;',
])
->values([
'collection' => 'system.schema',
'name' => 'jsonapi',
'value' => 'i:8000;',
])
->execute();
// Update core.extension.
$extensions = $connection->select('config')
->fields('config', ['data'])
->condition('collection', '')
->condition('name', 'core.extension')
->execute()
->fetchField();
$extensions = unserialize($extensions);
$extensions['module']['serialization'] = 0;
$extensions['module']['jsonapi'] = 0;
$connection->update('config')
->fields([
'data' => serialize($extensions),
])
->condition('collection', '')
->condition('name', 'core.extension')
->execute();
@@ -0,0 +1,4 @@
name: 'JSON API test collection counts'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,6 @@
services:
count.jsonapi.resource_type.repository:
class: Drupal\jsonapi_test_collection_count\ResourceType\CountableResourceTypeRepository
public: false
decorates: jsonapi.resource_type.repository
parent: jsonapi.resource_type.repository
@@ -0,0 +1,19 @@
<?php
namespace Drupal\jsonapi_test_collection_count\ResourceType;
use Drupal\jsonapi\ResourceType\ResourceType;
/**
* Subclass with overridden ::includeCount() for testing purposes.
*/
class CountableResourceType extends ResourceType {
/**
* {@inheritdoc}
*/
public function includeCount() {
return TRUE;
}
}
@@ -0,0 +1,30 @@
<?php
namespace Drupal\jsonapi_test_collection_count\ResourceType;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
/**
* Provides a repository of JSON:API configurable resource types.
*/
class CountableResourceTypeRepository extends ResourceTypeRepository {
/**
* {@inheritdoc}
*/
protected function createResourceType(EntityTypeInterface $entity_type, $bundle) {
$raw_fields = $this->getAllFieldNames($entity_type, $bundle);
return new CountableResourceType(
$entity_type->id(),
$bundle,
$entity_type->getClass(),
$entity_type->isInternal(),
static::isLocatableResourceType($entity_type, $bundle),
static::isMutableResourceType($entity_type, $bundle),
static::isVersionableResourceType($entity_type),
static::getFields($raw_fields, $entity_type, $bundle)
);
}
}
@@ -0,0 +1,4 @@
name: 'JSON API test format-agnostic @DataType normalizers'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,10 @@
services:
serializer.normalizer.string.jsonapi_test_data_type:
class: Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer
tags:
# The priority must be higher than serializer.normalizer.primitive_data.
- { name: normalizer , priority: 1000 }
serializer.normalizer.traversable_object.jsonapi_test_data_type:
class: Drupal\jsonapi_test_data_type\Normalizer\TraversableObjectNormalizer
tags:
- { name: normalizer }
@@ -0,0 +1,33 @@
<?php
namespace Drupal\jsonapi_test_data_type\Normalizer;
use Drupal\Core\TypedData\Plugin\DataType\StringData;
use Drupal\serialization\Normalizer\NormalizerBase;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Normalizes string data weirdly: replaces 'super' with 'NOT' and vice versa.
*/
class StringNormalizer extends NormalizerBase implements DenormalizerInterface {
/**
* {@inheritdoc}
*/
protected $supportedInterfaceOrClass = StringData::class;
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []) {
return str_replace('super', 'NOT', $object->getValue());
}
/**
* {@inheritdoc}
*/
public function denormalize($data, $class, $format = NULL, array $context = []) {
return str_replace('NOT', 'super', $data);
}
}
@@ -0,0 +1,25 @@
<?php
namespace Drupal\jsonapi_test_data_type\Normalizer;
use Drupal\jsonapi_test_data_type\TraversableObject;
use Drupal\serialization\Normalizer\NormalizerBase;
/**
* Normalizes TraversableObject.
*/
class TraversableObjectNormalizer extends NormalizerBase {
/**
* {@inheritdoc}
*/
protected $supportedInterfaceOrClass = TraversableObject::class;
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []) {
return $object->property;
}
}
@@ -0,0 +1,19 @@
<?php
namespace Drupal\jsonapi_test_data_type;
/**
* An object which implements \IteratorAggregate.
*/
class TraversableObject implements \IteratorAggregate {
public $property = "value";
/**
* {@inheritdoc}
*/
public function getIterator() {
return new \ArrayIterator();
}
}
@@ -0,0 +1,5 @@
name: 'JSON API field access'
type: module
description: 'Provides a custom field access hook to test JSON API field access security.'
package: Testing
core: 8.x
@@ -0,0 +1,28 @@
<?php
/**
* @file
* Contains hook implementations for testing the JSON:API module.
*/
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_entity_field_access().
*/
function jsonapi_test_field_access_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account) {
// @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::testRelationships().
if ($field_definition->getName() === 'field_jsonapi_test_entity_ref') {
// Forbid access in all cases.
$permission = "field_jsonapi_test_entity_ref $operation access";
$access_result = $account->hasPermission($permission)
? AccessResult::allowed()
: AccessResult::forbidden("The '$permission' permission is required.");
return $access_result->addCacheContexts(['user.permissions']);
}
// No opinion.
return AccessResult::neutral();
}
@@ -0,0 +1,4 @@
name: 'JSON:API test field aliasing'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,6 @@
services:
jsonapi.resource_type.repository.jsonapi_test_field_aliasing:
class: Drupal\jsonapi_test_field_aliasing\ResourceType\AliasingResourceTypeRepository
public: false
decorates: jsonapi.resource_type.repository
parent: jsonapi.resource_type.repository
@@ -0,0 +1,26 @@
<?php
namespace Drupal\jsonapi_test_field_aliasing\ResourceType;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
/**
* Provides a repository of JSON:API resource types with aliasable field names.
*/
class AliasingResourceTypeRepository extends ResourceTypeRepository {
/**
* {@inheritdoc}
*/
protected function getFields(array $field_names, EntityTypeInterface $entity_type, $bundle) {
$fields = parent::getFields($field_names, $entity_type, $bundle);
foreach ($fields as $field_name => $field) {
if (strpos($field_name, 'field_test_alias_') === 0) {
$fields[$field_name] = $fields[$field_name]->withPublicName('field_test_alias');
}
}
return $fields;
}
}
@@ -0,0 +1,5 @@
name: 'JSON:API filter access'
type: module
description: 'Provides custom access related code to test JSON:API filter security.'
package: Testing
core: 8.x
@@ -0,0 +1,23 @@
<?php
/**
* @file
* Contains hook implementations for testing the JSON:API module.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_jsonapi_entity_field_field_access().
*/
function jsonapi_test_field_filter_access_jsonapi_entity_field_filter_access(FieldDefinitionInterface $field_definition, AccountInterface $account) {
if ($field_definition->getName() === 'spotlight') {
return AccessResult::forbiddenIf(!$account->hasPermission('filter by spotlight field'))->cachePerPermissions();
}
if ($field_definition->getName() === 'field_test_text') {
return AccessResult::allowedIf($field_definition->getTargetEntityTypeId() === 'entity_test_with_bundle');
}
return AccessResult::neutral();
}
@@ -0,0 +1,4 @@
name: 'JSON API test format-agnostic @FieldType normalizers'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,6 @@
services:
serializer.normalizer.string.jsonapi_test_field_type:
class: Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer
tags:
# The priority must be higher than serialization.normalizer.field_item.
- { name: normalizer , priority: 1000 }
@@ -0,0 +1,37 @@
<?php
namespace Drupal\jsonapi_test_field_type\Normalizer;
use Drupal\Core\Field\Plugin\Field\FieldType\StringItem;
use Drupal\serialization\Normalizer\FieldItemNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Normalizes string fields weirdly: replaces 'super' with 'NOT' and vice versa.
*/
class StringNormalizer extends FieldItemNormalizer implements DenormalizerInterface {
/**
* {@inheritdoc}
*/
protected $supportedInterfaceOrClass = StringItem::class;
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []) {
$data = parent::normalize($object, $format, $context);
$data['value'] = str_replace('super', 'NOT', $data['value']);
return $data;
}
/**
* {@inheritdoc}
*/
protected function constructValue($data, $context) {
$data = parent::constructValue($data, $context);
$data['value'] = str_replace('NOT', 'super', $data['value']);
return $data;
}
}
@@ -0,0 +1,4 @@
name: 'JSON API test: normalizers kernel tests, public aliases for select JSON API normalizers'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,4 @@
services:
jsonapi_test_normalizers_kernel.jsonapi_document_toplevel:
alias: serializer.normalizer.jsonapi_document_toplevel.jsonapi
public: true
@@ -0,0 +1,4 @@
name: 'JSON:API test resource type building API'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,5 @@
services:
jsonapi_test_resource_type_building.build_subscriber:
class: Drupal\jsonapi_test_resource_type_building\EventSubscriber\ResourceTypeBuildEventSubscriber
tags:
- { name: event_subscriber }
@@ -0,0 +1,78 @@
<?php
namespace Drupal\jsonapi_test_resource_type_building\EventSubscriber;
use Drupal\jsonapi\ResourceType\ResourceTypeBuildEvents;
use Drupal\jsonapi\ResourceType\ResourceTypeBuildEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Event subscriber which tests disabling resource types.
*
* @internal
*/
class ResourceTypeBuildEventSubscriber implements EventSubscriberInterface {
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
ResourceTypeBuildEvents::BUILD => [
['disableResourceType'],
['aliasResourceTypeFields'],
['disableResourceTypeFields'],
],
];
}
/**
* Disables any resource types that have been disabled by a test.
*
* @param \Drupal\jsonapi\ResourceType\ResourceTypeBuildEvent $event
* The build event.
*/
public function disableResourceType(ResourceTypeBuildEvent $event) {
$disabled_resource_types = \Drupal::state()->get('jsonapi_test_resource_type_builder.disabled_resource_types', []);
if (in_array($event->getResourceTypeName(), $disabled_resource_types, TRUE)) {
$event->disableResourceType();
}
}
/**
* Aliases any resource type fields that have been aliased by a test.
*
* @param \Drupal\jsonapi\ResourceType\ResourceTypeBuildEvent $event
* The build event.
*/
public function aliasResourceTypeFields(ResourceTypeBuildEvent $event) {
$aliases = \Drupal::state()->get('jsonapi_test_resource_type_builder.resource_type_field_aliases', []);
$resource_type_name = $event->getResourceTypeName();
if (in_array($resource_type_name, array_keys($aliases), TRUE)) {
foreach ($event->getFields() as $field) {
if (isset($aliases[$resource_type_name][$field->getInternalName()])) {
$event->setPublicFieldName($field, $aliases[$resource_type_name][$field->getInternalName()]);
}
}
}
}
/**
* Disables any resource type fields that have been aliased by a test.
*
* @param \Drupal\jsonapi\ResourceType\ResourceTypeBuildEvent $event
* The build event.
*/
public function disableResourceTypeFields(ResourceTypeBuildEvent $event) {
$aliases = \Drupal::state()->get('jsonapi_test_resource_type_builder.disabled_resource_type_fields', []);
$resource_type_name = $event->getResourceTypeName();
if (in_array($resource_type_name, array_keys($aliases), TRUE)) {
foreach ($event->getFields() as $field) {
if (isset($aliases[$resource_type_name][$field->getInternalName()]) && $aliases[$resource_type_name][$field->getInternalName()] === TRUE) {
$event->disableField($field);
}
}
}
}
}
@@ -0,0 +1,4 @@
name: 'JSON:API user tests'
type: module
package: Testing
core: 8.x
@@ -0,0 +1,17 @@
<?php
/**
* @file
* Support module for JSON:API user hooks testing.
*/
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_user_format_name_alter().
*/
function jsonapi_test_user_user_format_name_alter(&$name, AccountInterface $account) {
if ($account->isAnonymous()) {
$name = 'User ' . $account->id();
}
}
@@ -0,0 +1,117 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\system\Entity\Action;
use Drupal\user\RoleInterface;
/**
* JSON:API integration test for the "Action" config entity type.
*
* @group jsonapi
*/
class ActionTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['user'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'action';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'action--action';
/**
* {@inheritdoc}
*
* @var \Drupal\system\ActionConfigEntityInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer actions']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$action = Action::create([
'id' => 'user_add_role_action.' . RoleInterface::ANONYMOUS_ID,
'type' => 'user',
'label' => t('Add the anonymous role to the selected users'),
'configuration' => [
'rid' => RoleInterface::ANONYMOUS_ID,
],
'plugin' => 'user_add_role_action',
]);
$action->save();
return $action;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/action/action/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'action--action',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'configuration' => [
'rid' => 'anonymous',
],
'dependencies' => [
'config' => ['user.role.anonymous'],
'module' => ['user'],
],
'label' => 'Add the anonymous role to the selected users',
'langcode' => 'en',
'plugin' => 'user_add_role_action',
'status' => TRUE,
'action_type' => 'user',
'drupal_internal__id' => 'user_add_role_action.anonymous',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,148 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Field\Entity\BaseFieldOverride;
use Drupal\Core\Url;
use Drupal\node\Entity\NodeType;
/**
* JSON:API integration test for the "BaseFieldOverride" config entity type.
*
* @group jsonapi
*/
class BaseFieldOverrideTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['field', 'node'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'base_field_override';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'base_field_override--base_field_override';
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Field\Entity\BaseFieldOverride
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer node fields']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
]);
$camelids->save();
$entity = BaseFieldOverride::create([
'field_name' => 'promote',
'entity_type' => 'node',
'bundle' => 'camelids',
]);
$entity->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/base_field_override/base_field_override/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'base_field_override--base_field_override',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'bundle' => 'camelids',
'default_value' => [],
'default_value_callback' => '',
'dependencies' => [
'config' => [
'node.type.camelids',
],
],
'description' => '',
'entity_type' => 'node',
'field_name' => 'promote',
'field_type' => 'boolean',
'label' => NULL,
'langcode' => 'en',
'required' => FALSE,
'settings' => [
'on_label' => 'On',
'off_label' => 'Off',
],
'status' => TRUE,
'translatable' => TRUE,
'drupal_internal__id' => 'node.camelids.promote',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer node fields' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
$entity = BaseFieldOverride::create([
'field_name' => 'status',
'entity_type' => 'node',
'bundle' => 'camelids',
]);
$entity->save();
return $entity;
}
}
@@ -0,0 +1,214 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\block_content\Entity\BlockContent;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
/**
* JSON:API integration test for the "BlockContent" content entity type.
*
* @group jsonapi
*/
class BlockContentTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['block_content'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'block_content';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'block_content--basic';
/**
* {@inheritdoc}
*
* @var \Drupal\block_content\BlockContentInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'changed' => NULL,
];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer blocks']);
}
/**
* {@inheritdoc}
*/
public function createEntity() {
if (!BlockContentType::load('basic')) {
$block_content_type = BlockContentType::create([
'id' => 'basic',
'label' => 'basic',
'revision' => FALSE,
]);
$block_content_type->save();
block_content_add_body_field($block_content_type->id());
}
// Create a "Llama" custom block.
$block_content = BlockContent::create([
'info' => 'Llama',
'type' => 'basic',
'body' => [
'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'format' => 'plain_text',
],
])
->setUnpublished();
$block_content->save();
return $block_content;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/block_content/basic/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'block_content--basic',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'body' => [
'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'format' => 'plain_text',
'summary' => NULL,
'processed' => "<p>The name &quot;llama&quot; was adopted by European settlers from native Peruvians.</p>\n",
],
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'info' => 'Llama',
'revision_log' => NULL,
'revision_created' => (new \DateTime())->setTimestamp($this->entity->getRevisionCreationTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'revision_translation_affected' => TRUE,
'status' => FALSE,
'langcode' => 'en',
'default_langcode' => TRUE,
'drupal_internal__id' => 1,
'drupal_internal__revision_id' => 1,
'reusable' => TRUE,
],
'relationships' => [
'block_content_type' => [
'data' => [
'id' => BlockContentType::load('basic')->uuid(),
'type' => 'block_content_type--block_content_type',
],
'links' => [
'related' => ['href' => $self_url . '/block_content_type'],
'self' => ['href' => $self_url . '/relationships/block_content_type'],
],
],
'revision_user' => [
'data' => NULL,
'links' => [
'related' => ['href' => $self_url . '/revision_user'],
'self' => ['href' => $self_url . '/relationships/revision_user'],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'block_content--basic',
'attributes' => [
'info' => 'Dramallama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// @see \Drupal\block_content\BlockContentAccessControlHandler()
return parent::getExpectedUnauthorizedAccessCacheability()
->addCacheTags(['block_content:1']);
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
$tags = parent::getExpectedCacheTags($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('body', $sparse_fieldset)) {
$tags = Cache::mergeTags($tags, ['config:filter.format.plain_text']);
}
return $tags;
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
$contexts = parent::getExpectedCacheContexts($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('body', $sparse_fieldset)) {
$contexts = Cache::mergeContexts($contexts, ['languages:language_interface', 'theme']);
}
return $contexts;
}
/**
* {@inheritdoc}
*/
public function testRelated() {
$this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$this->entity->setPublished()->save();
$this->doTestCollectionFilterAccessForPublishableEntities('info', NULL, 'administer blocks');
}
}
@@ -0,0 +1,108 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "BlockContentType" config entity type.
*
* @group jsonapi
*/
class BlockContentTypeTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['block_content'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'block_content_type';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'block_content_type--block_content_type';
/**
* {@inheritdoc}
*
* @var \Drupal\block_content\BlockContentTypeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer blocks']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$block_content_type = BlockContentType::create([
'id' => 'pascal',
'label' => 'Pascal',
'revision' => FALSE,
'description' => 'Provides a competitive alternative to the "basic" type',
]);
$block_content_type->save();
return $block_content_type;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/block_content_type/block_content_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'block_content_type--block_content_type',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'description' => 'Provides a competitive alternative to the "basic" type',
'label' => 'Pascal',
'langcode' => 'en',
'revision' => 0,
'status' => TRUE,
'drupal_internal__id' => 'pascal',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,195 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\block\Entity\Block;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "Block" config entity type.
*
* @group jsonapi
*/
class BlockTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['block'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'block';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'block--block';
/**
* {@inheritdoc}
*
* @var \Drupal\block\BlockInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'classy';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->entity->setVisibilityConfig('user_role', [])->save();
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$block = Block::create([
'plugin' => 'llama_block',
'region' => 'header',
'id' => 'llama',
'theme' => 'classy',
]);
// All blocks can be viewed by the anonymous user by default. An interesting
// side effect of this is that any anonymous user is also able to read the
// corresponding block config entity via REST, even if an authentication
// provider is configured for the block config entity REST resource! In
// other words: Block entities do not distinguish between 'view' as in
// "render on a page" and 'view' as in "read the configuration".
// This prevents that.
// @todo Fix this in https://www.drupal.org/node/2820315.
$block->setVisibilityConfig('user_role', [
'id' => 'user_role',
'roles' => ['non-existing-role' => 'non-existing-role'],
'negate' => FALSE,
'context_mapping' => [
'user' => '@user.current_user_context:current_user',
],
]);
$block->save();
return $block;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/block/block/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'block--block',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'weight' => NULL,
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [
'theme' => [
'classy',
],
],
'theme' => 'classy',
'region' => 'header',
'provider' => NULL,
'plugin' => 'llama_block',
'settings' => [
'id' => 'broken',
'label' => '',
'provider' => 'core',
'label_display' => 'visible',
],
'visibility' => [],
'drupal_internal__id' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update once https://www.drupal.org/node/2300677 is fixed.
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
// @see ::createEntity()
return array_values(array_diff(parent::getExpectedCacheContexts(), ['user.permissions']));
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
// Because the 'user.permissions' cache context is missing, the cache tag
// for the anonymous user role is never added automatically.
return array_values(array_diff(parent::getExpectedCacheTags(), ['config:user.role.anonymous']));
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The block visibility condition 'user_role' denied access.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// @see \Drupal\block\BlockAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
->setCacheTags([
'4xx-response',
'config:block.block.llama',
'http_response',
'user:2',
])
->setCacheContexts(['url.site', 'user.roles']);
}
/**
* {@inheritdoc}
*/
protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
return parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered)
->addCacheTags(['user:2'])
->addCacheContexts(['user.roles']);
}
}
@@ -0,0 +1,465 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\comment\Entity\Comment;
use Drupal\comment\Entity\CommentType;
use Drupal\comment\Tests\CommentTestTrait;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "Comment" content entity type.
*
* @group jsonapi
*/
class CommentTest extends ResourceTestBase {
use CommentTestTrait;
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['comment', 'entity_test'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'comment';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'comment--comment';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'status' => "The 'administer comments' permission is required.",
'name' => "The 'administer comments' permission is required.",
'homepage' => "The 'administer comments' permission is required.",
'created' => "The 'administer comments' permission is required.",
'changed' => NULL,
'thread' => NULL,
'entity_type' => NULL,
'field_name' => NULL,
// @todo Uncomment this after https://www.drupal.org/project/drupal/issues/1847608 lands. Until then, it's impossible to test this.
// 'pid' => NULL,
'uid' => "The 'administer comments' permission is required.",
'entity_id' => NULL,
];
/**
* {@inheritdoc}
*
* @var \Drupal\comment\CommentInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access comments', 'view test entity']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['post comments']);
break;
case 'PATCH':
$this->grantPermissionsToTestedRole(['edit own comments']);
break;
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer comments']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "bar" bundle for the "entity_test" entity type and create.
$bundle = 'bar';
entity_test_create_bundle($bundle, NULL, 'entity_test');
// Create a comment field on this bundle.
$this->addDefaultCommentField('entity_test', 'bar', 'comment');
// Create a "Camelids" test entity that the comment will be assigned to.
$commented_entity = EntityTest::create([
'name' => 'Camelids',
'type' => 'bar',
]);
$commented_entity->save();
// Create a "Llama" comment.
$comment = Comment::create([
'comment_body' => [
'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'format' => 'plain_text',
],
'entity_id' => $commented_entity->id(),
'entity_type' => 'entity_test',
'field_name' => 'comment',
]);
$comment->setSubject('Llama')
->setOwnerId($this->account->id())
->setPublished()
->setCreatedTime(123456789)
->setChangedTime(123456789);
$comment->save();
return $comment;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/comment/comment/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
$author = User::load($this->entity->getOwnerId());
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'comment--comment',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'created' => '1973-11-29T21:33:09+00:00',
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'comment_body' => [
'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'format' => 'plain_text',
'processed' => "<p>The name &quot;llama&quot; was adopted by European settlers from native Peruvians.</p>\n",
],
'default_langcode' => TRUE,
'entity_type' => 'entity_test',
'field_name' => 'comment',
'homepage' => NULL,
'langcode' => 'en',
'name' => NULL,
'status' => TRUE,
'subject' => 'Llama',
'thread' => '01/',
'drupal_internal__cid' => 1,
],
'relationships' => [
'uid' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => ['href' => $self_url . '/uid'],
'self' => ['href' => $self_url . '/relationships/uid'],
],
],
'comment_type' => [
'data' => [
'id' => CommentType::load('comment')->uuid(),
'type' => 'comment_type--comment_type',
],
'links' => [
'related' => ['href' => $self_url . '/comment_type'],
'self' => ['href' => $self_url . '/relationships/comment_type'],
],
],
'entity_id' => [
'data' => [
'id' => EntityTest::load(1)->uuid(),
'type' => 'entity_test--bar',
],
'links' => [
'related' => ['href' => $self_url . '/entity_id'],
'self' => ['href' => $self_url . '/relationships/entity_id'],
],
],
'pid' => [
'data' => NULL,
'links' => [
'related' => ['href' => $self_url . '/pid'],
'self' => ['href' => $self_url . '/relationships/pid'],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'comment--comment',
'attributes' => [
'entity_type' => 'entity_test',
'field_name' => 'comment',
'subject' => 'Dramallama',
'comment_body' => [
'value' => 'Llamas are awesome.',
'format' => 'plain_text',
],
],
'relationships' => [
'entity_id' => [
'data' => [
'type' => 'entity_test--bar',
'id' => EntityTest::load(1)->uuid(),
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
$tags = parent::getExpectedCacheTags($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('comment_body', $sparse_fieldset)) {
$tags = Cache::mergeTags($tags, ['config:filter.format.plain_text']);
}
return $tags;
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
$contexts = parent::getExpectedCacheContexts($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('comment_body', $sparse_fieldset)) {
$contexts = Cache::mergeContexts($contexts, ['languages:language_interface', 'theme']);
}
return $contexts;
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET';
return "The 'access comments' permission is required and the comment must be published.";
case 'POST';
return "The 'post comments' permission is required.";
case 'PATCH':
return "The 'edit own comments' permission is required, the user must be the comment author, and the comment must be published.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* Tests POSTing a comment without critical base fields.
*
* Note that testPostIndividual() is testing with the most minimal
* normalization possible: the one returned by ::getNormalizedPostEntity().
*
* But Comment entities have some very special edge cases:
* - base fields that are not marked as required in
* \Drupal\comment\Entity\Comment::baseFieldDefinitions() yet in fact are
* required.
* - base fields that are marked as required, but yet can still result in
* validation errors other than "missing required field".
*/
public function testPostIndividualDxWithoutCriticalBaseFields() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$remove_field = function (array $normalization, $type, $attribute_name) {
unset($normalization['data'][$type][$attribute_name]);
return $normalization;
};
// DX: 422 when missing 'entity_type' field.
$request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getPostDocument(), 'attributes', 'entity_type'));
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(422, 'entity_type: This value should not be null.', NULL, $response, '/data/attributes/entity_type');
// DX: 422 when missing 'entity_id' field.
$request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getPostDocument(), 'relationships', 'entity_id'));
// @todo Remove the try/catch in https://www.drupal.org/node/2820364.
try {
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(422, 'entity_id: This value should not be null.', NULL, $response, '/data/attributes/entity_id');
}
catch (\Exception $e) {
$this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage());
}
// DX: 422 when missing 'field_name' field.
$request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getPostDocument(), 'attributes', 'field_name'));
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(422, 'field_name: This value should not be null.', NULL, $response, '/data/attributes/field_name');
}
/**
* Tests POSTing a comment with and without 'skip comment approval'.
*/
public function testPostIndividualSkipCommentApproval() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// Create request.
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$request_options[RequestOptions::BODY] = Json::encode($this->getPostDocument());
$url = Url::fromRoute('jsonapi.comment--comment.collection.post');
// Status should be FALSE when posting as anonymous.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceResponse(201, FALSE, $response);
$this->assertFalse(Json::decode((string) $response->getBody())['data']['attributes']['status']);
$this->assertFalse($this->entityStorage->loadUnchanged(2)->isPublished());
// Grant anonymous permission to skip comment approval.
$this->grantPermissionsToTestedRole(['skip comment approval']);
// Status must be TRUE when posting as anonymous and skip comment approval.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceResponse(201, FALSE, $response);
$this->assertTrue(Json::decode((string) $response->getBody())['data']['attributes']['status']);
$this->assertTrue($this->entityStorage->loadUnchanged(3)->isPublished());
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
->addCacheTags(['comment:1']);
}
/**
* {@inheritdoc}
*/
protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// Also reset the 'entity_test' entity access control handler because
// comment access also depends on access to the commented entity type.
\Drupal::entityTypeManager()->getAccessControlHandler('entity_test')->resetCache();
return parent::entityAccess($entity, $operation, $account);
}
/**
* {@inheritdoc}
*/
public function testRelated() {
$this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
}
/**
* {@inheritdoc}
*/
protected static function getIncludePermissions() {
return [
'type' => ['administer comment types'],
'uid' => ['access user profiles'],
];
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
// Verify the expected behavior in the common case.
$this->doTestCollectionFilterAccessForPublishableEntities('subject', 'access comments', 'administer comments');
$collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// Go back to a simpler scenario: revoke the admin permission, publish the
// comment and uninstall the query access test module.
$this->revokePermissionsFromTestedRole(['administer comments']);
$this->entity->setPublished()->save();
$this->assertTrue($this->container->get('module_installer')->uninstall(['jsonapi_test_field_filter_access'], TRUE), 'Uninstalled modules.');
// ?filter[spotlight.LABEL]: 1 result. Just as already tested above in
// ::doTestCollectionFilterAccessForPublishableEntities().
$collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.subject]" => $this->entity->label()]);
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
// Mark the commented entity as inaccessible.
\Drupal::state()->set('jsonapi__entity_test_filter_access_blacklist', [$this->entity->getCommentedEntityId()]);
Cache::invalidateTags(['state:jsonapi__entity_test_filter_access_blacklist']);
// ?filter[spotlight.LABEL]: 0 results.
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
}
/**
* {@inheritdoc}
*/
protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
$cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
if ($filtered) {
$cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
}
return $cacheability;
}
/**
* {@inheritdoc}
*/
public function testPatchIndividual() {
// Ensure ::getModifiedEntityForPatchTesting() can pick an alternative value
// for the 'entity_id' field.
EntityTest::create([
'name' => $this->randomString(),
'type' => 'bar',
])->save();
return parent::testPatchIndividual();
}
}
@@ -0,0 +1,109 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\comment\Entity\CommentType;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "CommentType" config entity type.
*
* @group jsonapi
*/
class CommentTypeTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'comment'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'comment_type';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'comment_type--comment_type';
/**
* {@inheritdoc}
*
* @var \Drupal\comment\CommentTypeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer comment types']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" comment type.
$camelids = CommentType::create([
'id' => 'camelids',
'label' => 'Camelids',
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
'target_entity_type_id' => 'node',
]);
$camelids->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/comment_type/comment_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'comment_type--comment_type',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
'label' => 'Camelids',
'langcode' => 'en',
'status' => TRUE,
'target_entity_type_id' => 'node',
'drupal_internal__id' => 'camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,121 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\config_test\Entity\ConfigTest;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "ConfigTest" config entity type.
*
* @group jsonapi
*/
class ConfigTestTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['config_test', 'config_test_rest'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'config_test';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'config_test--config_test';
/**
* {@inheritdoc}
*
* @var \Drupal\config_test\ConfigTestInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['view config_test']);
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'view config_test' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$config_test = ConfigTest::create([
'id' => 'llama',
'label' => 'Llama',
]);
$config_test->save();
return $config_test;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/config_test/config_test/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'config_test--config_test',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'weight' => 0,
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [],
'label' => 'Llama',
'style' => NULL,
'size' => NULL,
'size_value' => NULL,
'protected_property' => NULL,
'drupal_internal__id' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,137 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "ConfigurableLanguage" config entity type.
*
* @group jsonapi
*/
class ConfigurableLanguageTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['language'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'configurable_language';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'configurable_language--configurable_language';
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Field\Entity\BaseFieldOverride
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer languages']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$configurable_language = ConfigurableLanguage::create([
'id' => 'll',
'label' => 'Llama Language',
]);
$configurable_language->save();
return $configurable_language;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/configurable_language/configurable_language/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'configurable_language--configurable_language',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'direction' => 'ltr',
'label' => 'Llama Language',
'langcode' => 'en',
'locked' => FALSE,
'status' => TRUE,
'weight' => 0,
'drupal_internal__id' => 'll',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['languages:language_interface']);
}
/**
* Test a GET request for a default config entity, which has a _core key.
*
* @see https://www.drupal.org/project/jsonapi/issues/2915539
*/
public function testGetIndividualDefaultConfig() {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute('jsonapi.configurable_language--configurable_language.individual', ['entity' => ConfigurableLanguage::load('en')->uuid()]);
/* $url = ConfigurableLanguage::load('en')->toUrl('jsonapi'); */
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$this->setUpAuthorization('GET');
$response = $this->request('GET', $url, $request_options);
$normalization = Json::decode((string) $response->getBody());
$this->assertArrayNotHasKey('_core', $normalization['data']['attributes']);
}
}
@@ -0,0 +1,124 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\contact\Entity\ContactForm;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "ContactForm" config entity type.
*
* @group jsonapi
*/
class ContactFormTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['contact'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'contact_form';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'contact_form--contact_form';
/**
* {@inheritdoc}
*
* @var \Drupal\contact\ContactFormInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['access site-wide contact form']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$contact_form = ContactForm::create([
'id' => 'llama',
'label' => 'Llama',
'message' => 'Let us know what you think about llamas',
'reply' => 'Llamas are indeed awesome!',
'recipients' => [
'llama@example.com',
'contact@example.com',
],
]);
$contact_form->save();
return $contact_form;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/contact_form/contact_form/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'contact_form--contact_form',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'label' => 'Llama',
'langcode' => 'en',
'message' => 'Let us know what you think about llamas',
'recipients' => [
'llama@example.com',
'contact@example.com',
],
'redirect' => NULL,
'reply' => 'Llamas are indeed awesome!',
'status' => TRUE,
'weight' => 0,
'drupal_internal__id' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'access site-wide contact form' permission is required.";
}
}
@@ -0,0 +1,158 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\language\Entity\ContentLanguageSettings;
use Drupal\node\Entity\NodeType;
/**
* JSON:API integration test for "ContentLanguageSettings" config entity type.
*
* @group jsonapi
*/
class ContentLanguageSettingsTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['language', 'node'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'language_content_settings';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'language_content_settings--language_content_settings';
/**
* {@inheritdoc}
*
* @var \Drupal\language\ContentLanguageSettingsInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer languages']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" node type.
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
]);
$camelids->save();
$entity = ContentLanguageSettings::create([
'target_entity_type_id' => 'node',
'target_bundle' => 'camelids',
]);
$entity->setDefaultLangcode('site_default')
->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/language_content_settings/language_content_settings/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'language_content_settings--language_content_settings',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'default_langcode' => 'site_default',
'dependencies' => [
'config' => [
'node.type.camelids',
],
],
'langcode' => 'en',
'language_alterable' => FALSE,
'status' => TRUE,
'target_bundle' => 'camelids',
'target_entity_type_id' => 'node',
'drupal_internal__id' => 'node.camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['languages:language_interface']);
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
NodeType::create([
'name' => 'Llamaids',
'type' => 'llamaids',
])->save();
$entity = ContentLanguageSettings::create([
'target_entity_type_id' => 'node',
'target_bundle' => 'llamaids',
]);
$entity->setDefaultLangcode('site_default');
$entity->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
$cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
if (static::entityAccess(reset($collection), 'view', $account)->isAllowed()) {
$cacheability->addCacheContexts(['languages:language_interface']);
}
return $cacheability;
}
}
@@ -0,0 +1,113 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Datetime\Entity\DateFormat;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "DateFormat" config entity type.
*
* @group jsonapi
*/
class DateFormatTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'date_format';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'date_format--date_format';
/**
* {@inheritdoc}
*/
protected static $anonymousUsersCanViewLabels = TRUE;
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Datetime\DateFormatInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer site configuration']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a date format.
$date_format = DateFormat::create([
'id' => 'llama',
'label' => 'Llama',
'pattern' => 'F d, Y',
]);
$date_format->save();
return $date_format;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/date_format/date_format/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'date_format--date_format',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'label' => 'Llama',
'langcode' => 'en',
'locked' => FALSE,
'pattern' => 'F d, Y',
'status' => TRUE,
'drupal_internal__id' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,253 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
/**
* JSON:API integration test for the "Editor" config entity type.
*
* @group jsonapi
*/
class EditorTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['filter', 'editor', 'ckeditor'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'editor';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'editor--editor';
/**
* {@inheritdoc}
*
* @var \Drupal\editor\EditorInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer filters']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Llama" filter format.
$llama_format = FilterFormat::create([
'name' => 'Llama',
'format' => 'llama',
'langcode' => 'es',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <a> <b> <lo>',
],
],
],
]);
$llama_format->save();
// Create a "Camelids" editor.
$camelids = Editor::create([
'format' => 'llama',
'editor' => 'ckeditor',
]);
$camelids
->setImageUploadSettings([
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => '',
'height' => '',
],
])
->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/editor/editor/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'editor--editor',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [
'config' => [
'filter.format.llama',
],
'module' => [
'ckeditor',
],
],
'editor' => 'ckeditor',
'image_upload' => [
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
],
'langcode' => 'en',
'settings' => [
'toolbar' => [
'rows' => [
[
[
'name' => 'Formatting',
'items' => [
'Bold',
'Italic',
],
],
[
'name' => 'Links',
'items' => [
'DrupalLink',
'DrupalUnlink',
],
],
[
'name' => 'Lists',
'items' => [
'BulletedList',
'NumberedList',
],
],
[
'name' => 'Media',
'items' => [
'Blockquote',
'DrupalImage',
],
],
[
'name' => 'Tools',
'items' => [
'Source',
],
],
],
],
],
'plugins' => [
'language' => [
'language_list' => 'un',
],
],
],
'status' => TRUE,
'drupal_internal__format' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer filters' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
FilterFormat::create([
'name' => 'Pachyderm',
'format' => 'pachyderm',
'langcode' => 'fr',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <a> <b> <lo>',
],
],
],
])->save();
$entity = Editor::create([
'format' => 'pachyderm',
'editor' => 'ckeditor',
]);
$entity->setImageUploadSettings([
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => '',
'height' => '',
],
])->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// Also reset the 'filter_format' entity access control handler because
// editor access also depends on access to the configured filter format.
\Drupal::entityTypeManager()->getAccessControlHandler('filter_format')->resetCache();
return parent::entityAccess($entity, $operation, $account);
}
}
@@ -0,0 +1,204 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Url;
use Drupal\node\Entity\NodeType;
/**
* JSON:API integration test for the "EntityFormDisplay" config entity type.
*
* @group jsonapi
*/
class EntityFormDisplayTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_form_display';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'entity_form_display--entity_form_display';
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Entity\Display\EntityFormDisplayInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer node form display']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" node type.
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
]);
$camelids->save();
// Create a form display.
$form_display = EntityFormDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'camelids',
'mode' => 'default',
]);
$form_display->save();
return $form_display;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/entity_form_display/entity_form_display/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'entity_form_display--entity_form_display',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'bundle' => 'camelids',
'content' => [
'created' => [
'type' => 'datetime_timestamp',
'weight' => 10,
'region' => 'content',
'settings' => [],
'third_party_settings' => [],
],
'promote' => [
'type' => 'boolean_checkbox',
'settings' => [
'display_label' => TRUE,
],
'weight' => 15,
'region' => 'content',
'third_party_settings' => [],
],
'status' => [
'type' => 'boolean_checkbox',
'weight' => 120,
'region' => 'content',
'settings' => [
'display_label' => TRUE,
],
'third_party_settings' => [],
],
'sticky' => [
'type' => 'boolean_checkbox',
'settings' => [
'display_label' => TRUE,
],
'weight' => 16,
'region' => 'content',
'third_party_settings' => [],
],
'title' => [
'type' => 'string_textfield',
'weight' => -5,
'region' => 'content',
'settings' => [
'size' => 60,
'placeholder' => '',
],
'third_party_settings' => [],
],
'uid' => [
'type' => 'entity_reference_autocomplete',
'weight' => 5,
'settings' => [
'match_operator' => 'CONTAINS',
'match_limit' => 10,
'size' => 60,
'placeholder' => '',
],
'region' => 'content',
'third_party_settings' => [],
],
],
'dependencies' => [
'config' => [
'node.type.camelids',
],
],
'hidden' => [],
'langcode' => 'en',
'mode' => 'default',
'status' => NULL,
'targetEntityType' => 'node',
'drupal_internal__id' => 'node.camelids.default',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer node form display' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
NodeType::create([
'name' => 'Llamaids',
'type' => 'llamaids',
])->save();
$entity = EntityFormDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'llamaids',
'mode' => 'default',
]);
$entity->save();
return $entity;
}
}
@@ -0,0 +1,111 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\Entity\EntityFormMode;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "EntityFormMode" config entity type.
*
* @group jsonapi
*/
class EntityFormModeTest extends ResourceTestBase {
/**
* {@inheritdoc}
*
* @todo: Remove 'field_ui' when https://www.drupal.org/node/2867266.
*/
public static $modules = ['user', 'field_ui'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_form_mode';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'entity_form_mode--entity_form_mode';
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Entity\EntityFormModeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer display modes']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$entity_form_mode = EntityFormMode::create([
'id' => 'user.test',
'label' => 'Test',
'targetEntityType' => 'user',
]);
$entity_form_mode->save();
return $entity_form_mode;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/entity_form_mode/entity_form_mode/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'entity_form_mode--entity_form_mode',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'cache' => TRUE,
'dependencies' => [
'module' => [
'user',
],
],
'label' => 'Test',
'langcode' => 'en',
'status' => TRUE,
'targetEntityType' => 'user',
'drupal_internal__id' => 'user.test',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,168 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\entity_test\Entity\EntityTestMapField;
use Drupal\user\Entity\User;
/**
* JSON:API integration test for the "EntityTestMapField" content entity type.
*
* @group jsonapi
*/
class EntityTestMapFieldTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['entity_test'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_test_map_field';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'entity_test_map_field--entity_test_map_field';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* {@inheritdoc}
*
* @var \Drupal\entity_test\Entity\EntityTestMapField
*/
protected $entity;
/**
* The complex nested value to assign to a @FieldType=map field.
*
* @var array
*/
protected static $mapValue = [
'key1' => 'value',
'key2' => 'no, val you',
'π' => 3.14159,
TRUE => 42,
'nested' => [
'bird' => 'robin',
'doll' => 'Russian',
],
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer entity_test content']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$entity = EntityTestMapField::create([
'name' => 'Llama',
'type' => 'entity_test_map_field',
'data' => [
static::$mapValue,
],
]);
$entity->setOwnerId(0);
$entity->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/entity_test_map_field/entity_test_map_field/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
$author = User::load(0);
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'entity_test_map_field--entity_test_map_field',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'created' => (new \DateTime())->setTimestamp($this->entity->get('created')->value)->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'langcode' => 'en',
'name' => 'Llama',
'data' => static::$mapValue,
'drupal_internal__id' => 1,
],
'relationships' => [
'user_id' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => ['href' => $self_url . '/user_id'],
'self' => ['href' => $self_url . '/relationships/user_id'],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'entity_test_map_field--entity_test_map_field',
'attributes' => [
'name' => 'Dramallama',
'data' => static::$mapValue,
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer entity_test content' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function getSparseFieldSets() {
// EntityTestMapField's owner field name is `user_id`, not `uid`, which
// breaks nested sparse fieldset tests.
return array_diff_key(parent::getSparseFieldSets(), array_flip([
'nested_empty_fieldset',
'nested_fieldset_with_owner_fieldset',
]));
}
}
@@ -0,0 +1,202 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
use Drupal\user\Entity\User;
/**
* JSON:API integration test for the "EntityTest" content entity type.
*
* @group jsonapi
*/
class EntityTestTest extends ResourceTestBase {
use BcTimestampNormalizerUnixTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['entity_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_test';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'entity_test--entity_test';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* {@inheritdoc}
*
* @var \Drupal\entity_test\Entity\EntityTest
*/
protected $entity;
/**
* {@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']);
break;
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer entity_test content']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Set flag so that internal field 'internal_string_field' is created.
// @see entity_test_entity_base_field_info()
$this->container->get('state')->set('entity_test.internal_field', TRUE);
$field_storage_definition = BaseFieldDefinition::create('string')
->setLabel('Internal field')
->setInternal(TRUE);
\Drupal::entityDefinitionUpdateManager()->installFieldStorageDefinition('internal_string_field', 'entity_test', 'entity_test', $field_storage_definition);
$entity_test = EntityTest::create([
'name' => 'Llama',
'type' => 'entity_test',
// Set a value for the internal field to confirm that it will not be
// returned in normalization.
// @see entity_test_entity_base_field_info().
'internal_string_field' => [
'value' => 'This value shall not be internal!',
],
]);
$entity_test->setOwnerId(0);
$entity_test->save();
return $entity_test;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
$author = User::load(0);
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'entity_test--entity_test',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'created' => (new \DateTime())->setTimestamp($this->entity->get('created')->value)->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'field_test_text' => NULL,
'langcode' => 'en',
'name' => 'Llama',
'entity_test_type' => 'entity_test',
'drupal_internal__id' => 1,
],
'relationships' => [
'user_id' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => ['href' => $self_url . '/user_id'],
'self' => ['href' => $self_url . '/relationships/user_id'],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'entity_test--entity_test',
'attributes' => [
'name' => 'Dramallama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'view test entity' permission is required.";
case 'POST':
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'.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function getSparseFieldSets() {
// EntityTest's owner field name is `user_id`, not `uid`, which breaks
// nested sparse fieldset tests.
return array_diff_key(parent::getSparseFieldSets(), array_flip([
'nested_empty_fieldset',
'nested_fieldset_with_owner_fieldset',
]));
}
/**
* {@inheritdoc}
*/
protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
$cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
if ($filtered) {
$cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
}
return $cacheability;
}
}
@@ -0,0 +1,159 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\Url;
use Drupal\node\Entity\NodeType;
/**
* JSON:API integration test for the "EntityViewDisplay" config entity type.
*
* @group jsonapi
*/
class EntityViewDisplayTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_view_display';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'entity_view_display--entity_view_display';
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer node display']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" node type.
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
]);
$camelids->save();
// Create a view display.
$view_display = EntityViewDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'camelids',
'mode' => 'default',
'status' => TRUE,
]);
$view_display->save();
return $view_display;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/entity_view_display/entity_view_display/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'entity_view_display--entity_view_display',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'bundle' => 'camelids',
'content' => [
'links' => [
'region' => 'content',
'weight' => 100,
'settings' => [],
'third_party_settings' => [],
],
],
'dependencies' => [
'config' => [
'node.type.camelids',
],
'module' => [
'user',
],
],
'hidden' => [],
'langcode' => 'en',
'mode' => 'default',
'status' => TRUE,
'targetEntityType' => 'node',
'drupal_internal__id' => 'node.camelids.default',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer node display' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
NodeType::create([
'name' => 'Pachyderms',
'type' => 'pachyderms',
])->save();
$entity = EntityViewDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'pachyderms',
'mode' => 'default',
'status' => TRUE,
]);
$entity->save();
return $entity;
}
}
@@ -0,0 +1,111 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "EntityViewMode" config entity type.
*
* @group jsonapi
*/
class EntityViewModeTest extends ResourceTestBase {
/**
* {@inheritdoc}
*
* @todo: Remove 'field_ui' when https://www.drupal.org/node/2867266.
*/
public static $modules = ['user', 'field_ui'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_view_mode';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'entity_view_mode--entity_view_mode';
/**
* {@inheritdoc}
*
* @var \Drupal\Core\Entity\EntityViewModeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer display modes']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$entity_view_mode = EntityViewMode::create([
'id' => 'user.test',
'label' => 'Test',
'targetEntityType' => 'user',
]);
$entity_view_mode->save();
return $entity_view_mode;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/entity_view_mode/entity_view_mode/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'entity_view_mode--entity_view_mode',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'cache' => TRUE,
'dependencies' => [
'module' => [
'user',
],
],
'label' => 'Test',
'langcode' => 'en',
'status' => TRUE,
'targetEntityType' => 'user',
'drupal_internal__id' => 'user.test',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,67 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
use GuzzleHttp\RequestOptions;
/**
* Makes assertions about the JSON:API behavior for internal entities.
*
* @group jsonapi
*
* @internal
*/
class EntryPointTest extends BrowserTestBase {
use JsonApiRequestTestTrait;
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'jsonapi',
'basic_auth',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Test GETing the entry point.
*/
public function testEntryPoint() {
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$response = $this->request('GET', Url::fromUri('base://jsonapi'), $request_options);
$document = Json::decode((string) $response->getBody());
$expected_cache_contexts = [
'url.site',
'user.roles:authenticated',
];
$this->assertTrue($response->hasHeader('X-Drupal-Cache-Contexts'));
$optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
$this->assertSame($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
$links = $document['links'];
$this->assertRegExp('/.*\/jsonapi/', $links['self']['href']);
$this->assertRegExp('/.*\/jsonapi\/user\/user/', $links['user--user']['href']);
$this->assertRegExp('/.*\/jsonapi\/node_type\/node_type/', $links['node_type--node_type']['href']);
$this->assertArrayNotHasKey('meta', $document);
// A `me` link must be present for authenticated users.
$user = $this->createUser();
$request_options[RequestOptions::HEADERS]['Authorization'] = 'Basic ' . base64_encode($user->name->value . ':' . $user->passRaw);
$response = $this->request('GET', Url::fromUri('base://jsonapi'), $request_options);
$document = Json::decode((string) $response->getBody());
$this->assertArrayHasKey('meta', $document);
$this->assertStringEndsWith('/jsonapi/user/user/' . $user->uuid(), $document['meta']['links']['me']['href']);
}
}
@@ -0,0 +1,204 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
/**
* Asserts external normalizers are handled as expected by the JSON:API module.
*
* @see jsonapi.normalizers
*
* @group jsonapi
*/
class ExternalNormalizersTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The original value for the test field.
*
* @var string
*/
const VALUE_ORIGINAL = 'Llamas are super awesome!';
/**
* The expected overridden value for the test field.
*
* @see \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer
* @see \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer
*/
const VALUE_OVERRIDDEN = 'Llamas are NOT awesome!';
/**
* {@inheritdoc}
*/
protected static $modules = [
'jsonapi',
'entity_test',
];
/**
* The test entity.
*
* @var \Drupal\entity_test\Entity\EntityTest
*/
protected $entity;
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
// This test is not about access control at all, so allow anonymous users to
// view and create the test entities.
Role::load(RoleInterface::ANONYMOUS_ID)
->grantPermission('view test entity')
->grantPermission('create entity_test entity_test_with_bundle entities')
->save();
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
FieldStorageConfig::create([
'field_name' => 'field_test',
'type' => 'string',
'entity_type' => 'entity_test',
])
->save();
FieldConfig::create([
'field_name' => 'field_test',
'entity_type' => 'entity_test',
'bundle' => 'entity_test',
])
->save();
$this->entity = EntityTest::create([
'name' => 'Llama',
'type' => 'entity_test',
'field_test' => static::VALUE_ORIGINAL,
]);
$this->entity->save();
}
/**
* Tests a format-agnostic normalizer.
*
* @param string $test_module
* The test module to install, which comes with a high-priority normalizer.
* @param string $expected_value_jsonapi_normalization
* The expected JSON:API normalization of the tested field. Must be either
* - static::VALUE_ORIGINAL (normalizer IS NOT expected to override)
* - static::VALUE_OVERRIDDEN (normalizer IS expected to override)
* @param string $expected_value_jsonapi_denormalization
* The expected JSON:API denormalization of the tested field. Must be either
* - static::VALUE_OVERRIDDEN (denormalizer IS NOT expected to override)
* - static::VALUE_ORIGINAL (denormalizer IS expected to override)
*
* @dataProvider providerTestFormatAgnosticNormalizers
*/
public function testFormatAgnosticNormalizers($test_module, $expected_value_jsonapi_normalization, $expected_value_jsonapi_denormalization) {
assert(in_array($expected_value_jsonapi_normalization, [static::VALUE_ORIGINAL, static::VALUE_OVERRIDDEN], TRUE));
assert(in_array($expected_value_jsonapi_denormalization, [static::VALUE_ORIGINAL, static::VALUE_OVERRIDDEN], TRUE));
// Asserts the entity contains the value we set.
$this->assertSame(static::VALUE_ORIGINAL, $this->entity->field_test->value);
// Asserts normalizing the entity using core's 'serializer' service DOES
// yield the value we set.
$core_normalization = $this->container->get('serializer')->normalize($this->entity);
$this->assertSame(static::VALUE_ORIGINAL, $core_normalization['field_test'][0]['value']);
// Asserts denormalizing the entity using core's 'serializer' service DOES
// yield the value we set.
$core_normalization['field_test'][0]['value'] = static::VALUE_OVERRIDDEN;
$denormalized_entity = $this->container->get('serializer')->denormalize($core_normalization, EntityTest::class, 'json', []);
$this->assertInstanceOf(EntityTest::class, $denormalized_entity);
$this->assertSame(static::VALUE_OVERRIDDEN, $denormalized_entity->field_test->value);
// Install test module that contains a high-priority alternative normalizer.
$this->container->get('module_installer')->install([$test_module]);
$this->rebuildContainer();
// Asserts normalizing the entity using core's 'serializer' service DOES NOT
// ANYMORE yield the value we set.
$core_normalization = $this->container->get('serializer')->normalize($this->entity);
$this->assertSame(static::VALUE_OVERRIDDEN, $core_normalization['field_test'][0]['value']);
// Asserts denormalizing the entity using core's 'serializer' service DOES
// NOT ANYMORE yield the value we set.
$core_normalization = $this->container->get('serializer')->normalize($this->entity);
$core_normalization['field_test'][0]['value'] = static::VALUE_OVERRIDDEN;
$denormalized_entity = $this->container->get('serializer')->denormalize($core_normalization, EntityTest::class, 'json', []);
$this->assertInstanceOf(EntityTest::class, $denormalized_entity);
$this->assertSame(static::VALUE_ORIGINAL, $denormalized_entity->field_test->value);
// Asserts the expected JSON:API normalization.
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute('jsonapi.entity_test--entity_test.individual', ['entity' => $this->entity->uuid()]);
// $url = $this->entity->toUrl('jsonapi');
$client = $this->getSession()->getDriver()->getClient()->getClient();
$response = $client->request('GET', $url->setAbsolute(TRUE)->toString());
$document = Json::decode((string) $response->getBody());
$this->assertSame($expected_value_jsonapi_normalization, $document['data']['attributes']['field_test']);
// Asserts the expected JSON:API denormalization.
$request_options = [];
$request_options[RequestOptions::BODY] = Json::encode([
'data' => [
'type' => 'entity_test--entity_test',
'attributes' => [
'field_test' => static::VALUE_OVERRIDDEN,
],
],
]);
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$response = $client->request('POST', Url::fromRoute('jsonapi.entity_test--entity_test.collection.post')->setAbsolute(TRUE)->toString(), $request_options);
$document = Json::decode((string) $response->getBody());
$this->assertSame(static::VALUE_OVERRIDDEN, $document['data']['attributes']['field_test']);
$entity_type_manager = $this->container->get('entity_type.manager');
$uuid_key = $entity_type_manager->getDefinition('entity_test')->getKey('uuid');
$entities = $entity_type_manager
->getStorage('entity_test')
->loadByProperties([$uuid_key => $document['data']['id']]);
$created_entity = reset($entities);
$this->assertSame($expected_value_jsonapi_denormalization, $created_entity->field_test->value);
}
/**
* Data provider.
*
* @return array
* Test cases.
*/
public function providerTestFormatAgnosticNormalizers() {
return [
'Format-agnostic @FieldType-level normalizers SHOULD NOT be able to affect the JSON:API normalization' => [
'jsonapi_test_field_type',
// \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer::normalize()
static::VALUE_ORIGINAL,
// \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer::denormalize()
static::VALUE_OVERRIDDEN,
],
'Format-agnostic @DataType-level normalizers SHOULD be able to affect the JSON:API normalization' => [
'jsonapi_test_data_type',
// \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer::normalize()
static::VALUE_OVERRIDDEN,
// \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer::denormalize()
static::VALUE_ORIGINAL,
],
];
}
}
@@ -0,0 +1,187 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\aggregator\Entity\Feed;
use Drupal\Core\Url;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
/**
* JSON:API integration test for the "Feed" content entity type.
*
* @group jsonapi
*/
class FeedTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['aggregator'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'aggregator_feed';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'aggregator_feed--aggregator_feed';
/**
* {@inheritdoc}
*
* @var \Drupal\config_test\ConfigTestInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* {@inheritdoc}
*/
protected static $uniqueFieldNames = ['url'];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access news feeds']);
break;
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer news feeds']);
break;
}
}
/**
* {@inheritdoc}
*/
public function createEntity() {
$feed = Feed::create();
$feed->set('fid', 1)
->setTitle('Feed')
->setUrl('http://example.com/rss.xml')
->setDescription('Feed Resource Test 1')
->setRefreshRate(900)
->setLastCheckedTime(123456789)
->setQueuedTime(123456789)
->setWebsiteUrl('http://example.com')
->setImage('http://example.com/feed_logo')
->setHash('abcdefg')
->setEtag('hijklmn')
->setLastModified(123456789)
->save();
return $feed;
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
/* @var \Drupal\aggregator\FeedInterface $duplicate */
$duplicate = $this->getEntityDuplicate($this->entity, $key);
$duplicate->set('field_rest_test', 'Duplicate feed entity');
$duplicate->setUrl("http://example.com/$key.xml");
$duplicate->save();
return $duplicate;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/aggregator_feed/aggregator_feed/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'aggregator_feed--aggregator_feed',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'url' => 'http://example.com/rss.xml',
'title' => 'Feed',
'refresh' => 900,
'checked' => '1973-11-29T21:33:09+00:00',
'queued' => '1973-11-29T21:33:09+00:00',
'link' => 'http://example.com',
'description' => 'Feed Resource Test 1',
'image' => 'http://example.com/feed_logo',
'hash' => 'abcdefg',
'etag' => 'hijklmn',
'modified' => '1973-11-29T21:33:09+00:00',
'langcode' => 'en',
'drupal_internal__fid' => 1,
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'aggregator_feed--aggregator_feed',
'attributes' => [
'title' => 'Feed Resource Post Test',
'url' => 'http://example.com/feed',
'refresh' => 900,
'description' => 'Feed Resource Post Test Description',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access news feeds' permission is required.";
case 'POST':
case 'PATCH':
case 'DELETE':
return "The 'administer news feeds' permission is required.";
}
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$this->doTestCollectionFilterAccessBasedOnPermissions('title', 'access news feeds');
}
}
@@ -0,0 +1,181 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\node\Entity\NodeType;
/**
* JSON:API integration test for the "FieldConfig" config entity type.
*
* @group jsonapi
*/
class FieldConfigTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['field', 'node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'field_config';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'field_config--field_config';
/**
* {@inheritdoc}
*
* @var \Drupal\field\FieldConfigInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer node fields']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
]);
$camelids->save();
$field_storage = FieldStorageConfig::create([
'field_name' => 'field_llama',
'entity_type' => 'node',
'type' => 'text',
]);
$field_storage->save();
$entity = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'camelids',
]);
$entity->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/field_config/field_config/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'field_config--field_config',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'bundle' => 'camelids',
'default_value' => [],
'default_value_callback' => '',
'dependencies' => [
'config' => [
'field.storage.node.field_llama',
'node.type.camelids',
],
'module' => [
'text',
],
],
'description' => '',
'entity_type' => 'node',
'field_name' => 'field_llama',
'field_type' => 'text',
'label' => 'field_llama',
'langcode' => 'en',
'required' => FALSE,
'settings' => [],
'status' => TRUE,
'translatable' => TRUE,
'drupal_internal__id' => 'node.camelids.field_llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer node fields' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
NodeType::create([
'name' => 'Pachyderms',
'type' => 'pachyderms',
])->save();
$field_storage = FieldStorageConfig::create([
'field_name' => 'field_pachyderm',
'entity_type' => 'node',
'type' => 'text',
]);
$field_storage->save();
$entity = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'pachyderms',
]);
$entity->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// Also clear the 'field_storage_config' entity access handler cache because
// the 'field_config' access handler delegates access to it.
// @see \Drupal\field\FieldConfigAccessControlHandler::checkAccess()
\Drupal::entityTypeManager()->getAccessControlHandler('field_storage_config')->resetCache();
return parent::entityAccess($entity, $operation, $account);
}
}
@@ -0,0 +1,124 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldStorageConfig;
/**
* JSON:API integration test for the "FieldStorageConfig" config entity type.
*
* @group jsonapi
*/
class FieldStorageConfigTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'field_storage_config';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'field_storage_config--field_storage_config';
/**
* {@inheritdoc}
*
* @var \Drupal\field\FieldConfigStorage
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer node fields']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$field_storage = FieldStorageConfig::create([
'field_name' => 'true_llama',
'entity_type' => 'node',
'type' => 'boolean',
]);
$field_storage->save();
return $field_storage;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/field_storage_config/field_storage_config/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'field_storage_config--field_storage_config',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'cardinality' => 1,
'custom_storage' => FALSE,
'dependencies' => [
'module' => [
'node',
],
],
'entity_type' => 'node',
'field_name' => 'true_llama',
'indexes' => [],
'langcode' => 'en',
'locked' => FALSE,
'module' => 'core',
'persist_with_no_fields' => FALSE,
'settings' => [],
'status' => TRUE,
'translatable' => TRUE,
'field_storage_config_type' => 'boolean',
'drupal_internal__id' => 'node.true_llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer node fields' permission is required.";
}
}
@@ -0,0 +1,249 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "File" content entity type.
*
* @group jsonapi
*/
class FileTest extends ResourceTestBase {
use BcTimestampNormalizerUnixTestTrait;
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['file', 'user'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'file';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'file--file';
/**
* {@inheritdoc}
*
* @var \Drupal\file\FileInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'uri' => NULL,
'filemime' => NULL,
'filesize' => NULL,
'status' => NULL,
'changed' => NULL,
];
/**
* The file author.
*
* @var \Drupal\user\UserInterface
*/
protected $author;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access content']);
break;
case 'PATCH':
case 'DELETE':
// \Drupal\file\FileAccessControlHandler::checkAccess() grants 'update'
// and 'delete' access only to the user that owns the file. So there is
// no permission to grant: instead, the file owner must be changed from
// its default (user 1) to the current user.
$this->makeCurrentUserFileOwner();
break;
}
}
/**
* Makes the current user the file owner.
*/
protected function makeCurrentUserFileOwner() {
$account = User::load(2);
$this->entity->setOwnerId($account->id());
$this->entity->setOwner($account);
$this->entity->save();
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$this->author = User::load(1);
$file = File::create();
$file->setOwnerId($this->author->id());
$file->setFilename('drupal.txt');
$file->setMimeType('text/plain');
$file->setFileUri('public://drupal.txt');
$file->set('status', FILE_STATUS_PERMANENT);
$file->save();
file_put_contents($file->getFileUri(), 'Drupal');
return $file;
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
/* @var \Drupal\file\FileInterface $duplicate */
$duplicate = parent::createAnotherEntity($key);
$duplicate->setFileUri("public://$key.txt");
$duplicate->save();
return $duplicate;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/file/file/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'file--file',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'created' => (new \DateTime())->setTimestamp($this->entity->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'filemime' => 'text/plain',
'filename' => 'drupal.txt',
'filesize' => (int) $this->entity->getSize(),
'langcode' => 'en',
'status' => TRUE,
'uri' => [
'url' => base_path() . $this->siteDirectory . '/files/drupal.txt',
'value' => 'public://drupal.txt',
],
'drupal_internal__fid' => 1,
],
'relationships' => [
'uid' => [
'data' => [
'id' => $this->author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => ['href' => $self_url . '/uid'],
'self' => ['href' => $self_url . '/relationships/uid'],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'file--file',
'attributes' => [
'filename' => 'drupal.txt',
],
],
];
}
/**
* {@inheritdoc}
*/
public function testPostIndividual() {
// @todo https://www.drupal.org/node/1927648
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
if ($method === 'GET') {
return "The 'access content' permission is required.";
}
if ($method === 'PATCH' || $method === 'DELETE') {
return "Only the file owner can update or delete the file entity.";
}
return parent::getExpectedUnauthorizedAccessMessage($method);
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$label_field_name = 'filename';
// Verify the expected behavior in the common case: when the file is public.
$this->doTestCollectionFilterAccessBasedOnPermissions($label_field_name, 'access content');
$collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
$collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// 1 result because the current user is the file owner, even though the file
// is private.
$this->entity->setFileUri('private://drupal.txt');
$this->entity->setOwner($this->account);
$this->entity->save();
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
// 0 results because the current user is no longer the file owner and the
// file is private.
$this->entity->setOwner(User::load(0));
$this->entity->save();
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
}
}
@@ -0,0 +1,864 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
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\user\Entity\User;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
/**
* Tests binary data file upload route.
*
* @group jsonapi
*/
class FileUploadTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['entity_test', 'file'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*
* @see $entity
*/
protected static $entityTypeId = 'entity_test';
/**
* {@inheritdoc}
*
* @see $entity
*/
protected static $resourceTypeName = 'entity_test--entity_test';
/**
* The POST URI.
*
* @var string
*/
protected static $postUri = '/jsonapi/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}
*/
public function setUp() {
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();
// Reload entity so that it has the new field.
$this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
$this->rebuildAll();
}
/**
* {@inheritdoc}
*
* @requires module irrelevant_for_this_test
*/
public function testGetIndividual() {}
/**
* {@inheritdoc}
*
* @requires module irrelevant_for_this_test
*/
public function testPostIndividual() {}
/**
* {@inheritdoc}
*
* @requires module irrelevant_for_this_test
*/
public function testPatchIndividual() {}
/**
* {@inheritdoc}
*
* @requires module irrelevant_for_this_test
*/
public function testDeleteIndividual() {}
/**
* {@inheritdoc}
*
* @requires module irrelevant_for_this_test
*/
public function testCollection() {}
/**
* {@inheritdoc}
*
* @requires module irrelevant_for_this_test
*/
public function testRelationships() {}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create an entity that a file can be attached to.
$entity_test = EntityTest::create([
'name' => 'Llama',
'type' => 'entity_test',
]);
$entity_test->setOwnerId($this->account->id());
$entity_test->save();
return $entity_test;
}
/**
* Tests using the file upload POST route; needs second request to "use" file.
*/
public function testPostFileUpload() {
$uri = Url::fromUri('base:' . static::$postUri);
// DX: 405 when read-only mode is enabled.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $uri, $response);
$this->assertSame(['GET'], $response->getHeader('Allow'));
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// DX: 403 when unauthorized.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $uri, $response);
$this->setUpAuthorization('POST');
// 404 when the field name is invalid.
$invalid_uri = Url::fromUri('base:' . static::$postUri . '_invalid');
$response = $this->fileRequest($invalid_uri, $this->testFileData);
$this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist.', $invalid_uri, $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->getExpectedDocument();
$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->getExpectedDocument(2, 'example_0.txt');
$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('jsonapi.entity_test--entity_test.collection.post');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$request_options[RequestOptions::BODY] = Json::encode($this->getPostDocument());
$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());
}
/**
* Tests using the 'file upload and "use" file in single request" POST route.
*/
public function testPostFileUploadAndUseInSingleRequest() {
// Update the test entity so it already has a file. This allows verifying
// that this route appends files, and does not replace them.
mkdir('public://foobar');
file_put_contents('public://foobar/existing.txt', $this->testFileData);
$existing_file = File::create([
'uri' => 'public://foobar/existing.txt',
]);
$existing_file->setOwnerId($this->account->id());
$existing_file->setPermanent();
$existing_file->save();
$this->entity
->set('field_rest_file_test', ['target_id' => $existing_file->id()])
->save();
$uri = Url::fromUri('base:' . '/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test');
// DX: 405 when read-only mode is enabled.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $uri, $response);
$this->assertSame(['GET'], $response->getHeader('Allow'));
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// DX: 403 when unauthorized.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $uri, $response);
$this->setUpAuthorization('PATCH');
// 404 when the field name is invalid.
$invalid_uri = Url::fromUri($uri->getUri() . '_invalid');
$response = $this->fileRequest($invalid_uri, $this->testFileData);
$this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist.', $invalid_uri, $response);
// This request fails despite the upload succeeding, because we're not
// allowed to view the entity we're uploading to.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.site', 'user.permissions']);
$this->setUpAuthorization('GET');
// Reuploading the same file will result in the file being uploaded twice
// and referenced twice.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(200, $response->getStatusCode());
$expected = [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test')->setAbsolute(TRUE)->toString()],
],
'data' => [
0 => $this->getExpectedDocument(1, 'existing.txt', TRUE, TRUE)['data'],
1 => $this->getExpectedDocument(2, 'example.txt', TRUE, TRUE)['data'],
2 => $this->getExpectedDocument(3, 'example_0.txt', FALSE, TRUE)['data'],
],
];
$this->assertResponseData($expected, $response);
// The response document received for the POST request is identical to the
// response document received by GETting the same URL.
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$response = $this->request('GET', $uri, $request_options);
$this->assertSame(200, $response->getStatusCode());
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
}
/**
* Returns the JSON:API POST document referencing the uploaded file.
*
* @return array
* A JSON:API request document.
*
* @see ::testPostFileUpload()
* @see \Drupal\Tests\jsonapi\Functional\EntityTestTest::getPostDocument()
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'entity_test--entity_test',
'attributes' => [
'name' => 'Dramallama',
],
'relationships' => [
'field_rest_file_test' => [
'data' => [
'id' => File::load(1)->uuid(),
'meta' => [
'description' => 'The most fascinating file ever!',
],
'type' => 'file--file',
],
],
],
],
];
}
/**
* Tests using the file upload POST route with invalid headers.
*/
public function testPostFileUploadInvalidHeaders() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$uri = Url::fromUri('base:' . static::$postUri);
// The wrong content type header should return a 415 code.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => 'application/vnd.api+json']);
$this->assertSame(415, $response->getStatusCode());
// 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.', $uri, $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.', $uri, $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.', $uri, $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.', $uri, $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.', $uri, $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() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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->getExpectedDocument(2, 'example_0.txt');
$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() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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."), $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() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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->getExpectedDocument();
$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->getExpectedDocument(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->assertFileNotExists('../../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->rebuildAll();
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="/etc/passwd"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedDocument(3, 'passwd', TRUE);
// This mime will be guessed as there is no extension.
$expected['data']['attributes']['filemime'] = '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() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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->getExpectedDocument(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() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$uri = Url::fromUri('base:' . static::$postUri);
// Test with a zero byte file.
$response = $this->fileRequest($uri, NULL);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedDocument();
// Modify the default expected data to account for the 0 byte file.
$expected['data']['attributes']['filesize'] = 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() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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>."), $uri, $response);
// Make sure that no file was saved.
$this->assertEmpty(File::load(1));
$this->assertFileNotExists('public://foobar/example.txt');
}
/**
* Tests using the file upload route with a file size larger than allowed.
*/
public function testFileUploadLargerFileSize() {
// Set a limit of 50 bytes.
$this->field->setSetting('max_filesize', 50)
->save();
$this->rebuildAll();
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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>."), $uri, $response);
// Make sure that no file was saved.
$this->assertEmpty(File::load(1));
$this->assertFileNotExists('public://foobar/example.txt');
}
/**
* Tests using the file upload POST route with malicious extensions.
*/
public function testFileUploadMaliciousExtension() {
// Allow all file uploads but system.file::allow_insecure_uploads is set to
// FALSE.
$this->field->setSetting('file_extensions', '')->save();
$this->rebuildAll();
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$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->getExpectedDocument(1, 'example.php.txt', TRUE);
// Override the expected filesize.
$expected['data']['attributes']['filesize'] = strlen($php_string);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example.php.txt');
// Add php as an allowed format. Allow insecure uploads still being FALSE
// should still not allow this. So it should still have a .txt extension
// appended even though it is not in the list of allowed extensions.
$this->field->setSetting('file_extensions', 'php')
->save();
$this->rebuildAll();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_2.php"']);
$expected = $this->getExpectedDocument(2, 'example_2.php.txt', TRUE);
// Override the expected filesize.
$expected['data']['attributes']['filesize'] = strlen($php_string);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_2.php.txt');
$this->assertFileNotExists('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->rebuildAll();
// 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->getExpectedDocument(3, 'example_3.php_.doc', TRUE);
// Override the expected filesize.
$expected['data']['attributes']['filesize'] = strlen($php_string);
// The file mime should be 'application/msword'.
$expected['data']['attributes']['filemime'] = 'application/msword';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_3.php_.doc');
$this->assertFileNotExists('public://foobar/example_3.php.doc');
// 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->rebuildAll();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_4.php"']);
$expected = $this->getExpectedDocument(4, 'example_4.php', TRUE);
// Override the expected filesize.
$expected['data']['attributes']['filesize'] = strlen($php_string);
// The file mime should also now be PHP.
$expected['data']['attributes']['filemime'] = 'application/x-httpd-php';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_4.php');
}
/**
* Tests using the file upload POST route no extension configured.
*/
public function testFileUploadNoExtensionSetting() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$uri = Url::fromUri('base:' . static::$postUri);
$this->field->setSetting('file_extensions', '')
->save();
$this->rebuildAll();
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
$expected = $this->getExpectedDocument(1, 'example.txt', TRUE);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example.txt');
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The current user is not allowed to view this relationship. The 'view test entity' permission is required.";
case 'POST':
return "The current user is not permitted to upload a file for this field. 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'.";
case 'PATCH':
return "The current user is not permitted to upload a file for this field. The 'administer entity_test content' permission is required.";
}
}
/**
* Returns the expected JSON:API document for the expected file entity.
*
* @param int $fid
* The file ID to load and create a JSON:API document 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.
* @param bool $expected_status
* The expected file status. Defaults to FALSE.
*
* @return array
* A JSON:API response document.
*/
protected function getExpectedDocument($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE, $expected_status = FALSE) {
$author = User::load($this->account->id());
$file = File::load($fid);
$self_url = Url::fromUri('base:/jsonapi/file/file/' . $file->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $file->uuid(),
'type' => 'file--file',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'created' => (new \DateTime())->setTimestamp($file->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'changed' => (new \DateTime())->setTimestamp($file->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'filemime' => 'text/plain',
'filename' => $expected_as_filename ? $expected_filename : 'example.txt',
'filesize' => strlen($this->testFileData),
'langcode' => 'en',
'status' => $expected_status,
'uri' => [
'value' => 'public://foobar/' . $expected_filename,
'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename),
],
'drupal_internal__fid' => (int) $file->id(),
],
'relationships' => [
'uid' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => ['href' => $self_url . '/uid'],
'self' => ['href' => $self_url . '/relationships/uid'],
],
],
],
],
];
}
/**
* Performs a file upload request. Wraps the Guzzle HTTP client.
*
* @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
* The received response.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function fileRequest(Url $url, $file_contents, array $headers = []) {
$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"',
// Set the required JSON:API Accept header.
'Accept' => 'application/vnd.api+json',
];
$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());
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;
case 'PATCH':
$this->grantPermissionsToTestedRole(['administer entity_test content', '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 = Json::decode((string) $response->getBody());
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.
}
}
@@ -0,0 +1,125 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\filter\Entity\FilterFormat;
/**
* JSON:API integration test for the "FilterFormat" config entity type.
*
* @group jsonapi
*/
class FilterFormatTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['filter'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'filter_format';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'filter_format--filter_format';
/**
* {@inheritdoc}
*
* @var \Drupal\filter\FilterFormatInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer filters']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$pablo_format = FilterFormat::create([
'name' => 'Pablo Picasso',
'format' => 'pablo',
'langcode' => 'es',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <a> <b> <lo>',
],
],
],
]);
$pablo_format->save();
return $pablo_format;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/filter_format/filter_format/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'filter_format--filter_format',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'filters' => [
'filter_html' => [
'id' => 'filter_html',
'provider' => 'filter',
'status' => TRUE,
'weight' => -10,
'settings' => [
'allowed_html' => '<p> <a> <b> <lo>',
'filter_html_help' => TRUE,
'filter_html_nofollow' => FALSE,
],
],
],
'langcode' => 'es',
'name' => 'Pablo Picasso',
'status' => TRUE,
'weight' => 0,
'drupal_internal__format' => 'pablo',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,135 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\image\Entity\ImageStyle;
/**
* JSON:API integration test for the "ImageStyle" config entity type.
*
* @group jsonapi
*/
class ImageStyleTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['image'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'image_style';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'image_style--image_style';
/**
* {@inheritdoc}
*
* @var \Drupal\image\ImageStyleInterface
*/
protected $entity;
/**
* The effect UUID.
*
* @var string
*/
protected $effectUuid;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer image styles']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" image style.
$camelids = ImageStyle::create([
'name' => 'camelids',
'label' => 'Camelids',
]);
// Add an image effect.
$effect = [
'id' => 'image_scale_and_crop',
'data' => [
'width' => 120,
'height' => 121,
],
'weight' => 0,
];
$this->effectUuid = $camelids->addImageEffect($effect);
$camelids->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/image_style/image_style/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'image_style--image_style',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'effects' => [
$this->effectUuid => [
'uuid' => $this->effectUuid,
'id' => 'image_scale_and_crop',
'weight' => 0,
'data' => [
'anchor' => 'center-center',
'width' => 120,
'height' => 121,
],
],
],
'label' => 'Camelids',
'langcode' => 'en',
'status' => TRUE,
'drupal_internal__name' => 'camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,192 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Entity\EntityInterface;
use Drupal\entity_test\Entity\EntityTestBundle;
use Drupal\entity_test\Entity\EntityTestNoLabel;
use Drupal\entity_test\Entity\EntityTestWithBundle;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
/**
* Makes assertions about the JSON:API behavior for internal entities.
*
* @group jsonapi
*
* @internal
*/
class InternalEntitiesTest extends BrowserTestBase {
use EntityReferenceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'jsonapi',
'entity_test',
'serialization',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A test user.
*
* @var \Drupal\user\UserInterface
*/
protected $testUser;
/**
* An entity of an internal entity type.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $internalEntity;
/**
* An entity referencing an internal entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $referencingEntity;
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$this->testUser = $this->drupalCreateUser([
'view test entity',
'administer entity_test_with_bundle content',
], $this->randomString(), TRUE);
EntityTestBundle::create([
'id' => 'internal_referencer',
'label' => 'Entity Test Internal Referencer',
])->save();
$this->createEntityReferenceField(
'entity_test_with_bundle',
'internal_referencer',
'field_internal',
'Internal Entities',
'entity_test_no_label'
);
$this->internalEntity = EntityTestNoLabel::create([]);
$this->internalEntity->save();
$this->referencingEntity = EntityTestWithBundle::create([
'type' => 'internal_referencer',
'field_internal' => $this->internalEntity->id(),
]);
$this->referencingEntity->save();
drupal_flush_all_caches();
}
/**
* Ensures that internal resources types aren't present in the entry point.
*/
public function testEntryPoint() {
$document = $this->jsonapiGet('/jsonapi');
$this->assertArrayNotHasKey(
"{$this->internalEntity->getEntityTypeId()}--{$this->internalEntity->bundle()}",
$document['links'],
'The entry point should not contain links to internal resource type routes.'
);
}
/**
* Ensures that internal resources types aren't present in the routes.
*/
public function testRoutes() {
// This cannot be in a data provider because it needs values created by the
// setUp method.
$paths = [
'individual' => "/jsonapi/entity_test_no_label/entity_test_no_label/{$this->internalEntity->uuid()}",
'collection' => "/jsonapi/entity_test_no_label/entity_test_no_label",
'related' => "/jsonapi/entity_test_no_label/entity_test_no_label/{$this->internalEntity->uuid()}/field_internal",
];
$this->drupalLogin($this->testUser);
foreach ($paths as $type => $path) {
$this->drupalGet($path, ['Accept' => 'application/vnd.api+json']);
$this->assertSame(404, $this->getSession()->getStatusCode());
}
}
/**
* Asserts that internal entities are not included in compound documents.
*/
public function testIncludes() {
$document = $this->getIndividual($this->referencingEntity, [
'query' => ['include' => 'field_internal'],
]);
$this->assertArrayNotHasKey(
'included',
$document,
'Internal entities should not be included in compound documents.'
);
}
/**
* Asserts that links to internal relationships aren't generated.
*/
public function testLinks() {
$document = $this->getIndividual($this->referencingEntity);
$this->assertArrayNotHasKey(
'related',
$document['data']['relationships']['field_internal']['links'],
'Links to internal-only related routes should not be in the document.'
);
}
/**
* Returns the decoded JSON:API document for the for the given entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to request.
* @param array $options
* URL options.
*
* @return array
* The decoded response document.
*/
protected function getIndividual(EntityInterface $entity, array $options = []) {
$entity_type_id = $entity->getEntityTypeId();
$bundle = $entity->bundle();
$path = "/jsonapi/{$entity_type_id}/{$bundle}/{$entity->uuid()}";
return $this->jsonapiGet($path, $options);
}
/**
* Performs an authenticated request and returns the decoded document.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to request.
* @param string $relationship
* The field name of the relationship to request.
* @param array $options
* URL options.
*
* @return array
* The decoded response document.
*/
protected function getRelated(EntityInterface $entity, $relationship, array $options = []) {
$entity_type_id = $entity->getEntityTypeId();
$bundle = $entity->bundle();
$path = "/jsonapi/{$entity_type_id}/{$bundle}/{$entity->uuid()}/{$relationship}";
return $this->jsonapiGet($path, $options);
}
/**
* Performs an authenticated request and returns the decoded document.
*/
protected function jsonapiGet($path, array $options = []) {
$this->drupalLogin($this->testUser);
$response = $this->drupalGet($path, $options, ['Accept' => 'application/vnd.api+json']);
return Json::decode($response);
}
}
@@ -0,0 +1,180 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\aggregator\Entity\Feed;
use Drupal\aggregator\Entity\Item;
use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
/**
* JSON:API integration test for the "Item" content entity type.
*
* @group jsonapi
*/
class ItemTest extends ResourceTestBase {
use BcTimestampNormalizerUnixTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['aggregator'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'aggregator_item';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'aggregator_item--aggregator_item';
/**
* {@inheritdoc}
*
* @var \Drupal\aggregator\ItemInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access news feeds']);
break;
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer news feeds']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" feed.
$feed = Feed::create([
'title' => 'Camelids',
'url' => 'https://groups.drupal.org/not_used/167169',
'refresh' => 900,
'checked' => 1389919932,
'description' => 'Drupal Core Group feed',
]);
$feed->save();
// Create a "Llama" item.
$item = Item::create();
$item->setTitle('Llama')
->setFeedId($feed->id())
->setLink('https://www.drupal.org/')
->setPostedTime(123456789)
->save();
return $item;
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
$duplicate = $this->getEntityDuplicate($this->entity, $key);
$duplicate->setLink('https://www.example.org/');
$duplicate->save();
return $duplicate;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access news feeds' permission is required.";
case 'POST':
case 'PATCH':
case 'DELETE':
return "The 'administer news feeds' permission is required.";
}
}
/**
* {@inheritdoc}
*/
public function testGetIndividual() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
/**
* {@inheritdoc}
*/
public function testCollection() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
/**
* {@inheritdoc}
*/
public function testRelated() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
/**
* {@inheritdoc}
*/
public function testRelationships() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
/**
* {@inheritdoc}
*/
public function testPostIndividual() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
/**
* {@inheritdoc}
*/
public function testPatchIndividual() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
/**
* {@inheritdoc}
*/
public function testDeleteIndividual() {
$this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
}
}
@@ -0,0 +1,325 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\language\Entity\ContentLanguageSettings;
use Drupal\node\Entity\Node;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
/**
* Tests JSON:API multilingual support.
*
* @group jsonapi
*
* @internal
*/
class JsonApiFunctionalMultilingualTest extends JsonApiFunctionalTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'language',
'content_translation',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$language = ConfigurableLanguage::createFromLangcode('ca');
$language->save();
ConfigurableLanguage::createFromLangcode('ca-fr')->save();
// In order to reflect the changes for a multilingual site in the container
// we have to rebuild it.
$this->rebuildContainer();
\Drupal::configFactory()->getEditable('language.negotiation')
->set('url.prefixes.ca', 'ca')
->set('url.prefixes.ca-fr', 'ca-fr')
->save();
ContentLanguageSettings::create([
'target_entity_type_id' => 'node',
'target_bundle' => 'article',
])
->setThirdPartySetting('content_translation', 'enabled', TRUE)
->save();
$this->createDefaultContent(5, 5, TRUE, TRUE, static::IS_MULTILINGUAL, FALSE);
}
/**
* Tests reading multilingual content.
*/
public function testReadMultilingual() {
// Different databases have different sort orders, so a sort is required so
// test expectations do not need to vary per database.
$default_sort = ['sort' => 'drupal_internal__nid'];
// Test reading an individual entity translation.
$output = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $this->nodes[0]->uuid(), ['query' => ['include' => 'field_tags,field_image'] + $default_sort]));
$this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data']['attributes']['title']);
$this->assertSame('ca', $output['data']['attributes']['langcode']);
$included_tags = array_filter($output['included'], function ($entry) {
return $entry['type'] === 'taxonomy_term--tags';
});
$tag_name = $this->nodes[0]->get('field_tags')->entity
->getTranslation('ca')->getName();
$this->assertEquals($tag_name, reset($included_tags)['attributes']['name']);
$alt = $this->nodes[0]->getTranslation('ca')->get('field_image')->alt;
$this->assertSame($alt, $output['data']['relationships']['field_image']['data']['meta']['alt']);
// Test reading an individual entity fallback.
$output = Json::decode($this->drupalGet('/ca-fr/jsonapi/node/article/' . $this->nodes[0]->uuid()));
$this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data']['attributes']['title']);
$output = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $this->nodes[0]->uuid(), ['query' => $default_sort]));
$this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data']['attributes']['title']);
// Test reading a collection of entities.
$output = Json::decode($this->drupalGet('/ca/jsonapi/node/article', ['query' => $default_sort]));
$this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data'][0]['attributes']['title']);
}
/**
* Tests updating a translation.
*/
public function testPatchTranslation() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$node = $this->nodes[0];
$uuid = $node->uuid();
// Assert the precondition: the 'ca' translation has a different title.
$document = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid));
$document_ca = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $uuid));
$this->assertSame('en', $document['data']['attributes']['langcode']);
$this->assertSame('ca', $document_ca['data']['attributes']['langcode']);
$this->assertSame($node->getTitle(), $document['data']['attributes']['title']);
$this->assertSame($node->getTitle() . ' (ca)', $document_ca['data']['attributes']['title']);
// PATCH the 'ca' translation.
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [
'bypass node access',
]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options[RequestOptions::BODY] = Json::encode([
'data' => [
'type' => 'node--article',
'id' => $uuid,
'attributes' => [
'title' => $document_ca['data']['attributes']['title'] . ' UPDATED',
],
],
]);
$response = $this->request('PATCH', Url::fromUri('base:/ca/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$this->assertSame(200, $response->getStatusCode());
// Assert the postcondition: only the 'ca' translation has an updated title.
$document_updated = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid));
$document_ca_updated = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $uuid));
$this->assertSame('en', $document_updated['data']['attributes']['langcode']);
$this->assertSame('ca', $document_ca_updated['data']['attributes']['langcode']);
$this->assertSame($node->getTitle(), $document_updated['data']['attributes']['title']);
$this->assertSame($node->getTitle() . ' (ca) UPDATED', $document_ca_updated['data']['attributes']['title']);
// Specifying a langcode is not allowed by default.
$request_options[RequestOptions::BODY] = Json::encode([
'data' => [
'type' => 'node--article',
'id' => $uuid,
'attributes' => [
'langcode' => 'ca-fr',
],
],
]);
$response = $this->request('PATCH', Url::fromUri('base:/ca/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$this->assertSame(403, $response->getStatusCode());
// Specifying a langcode is allowed once configured to be alterable. But
// modifying the language of a non-default translation is still not allowed.
ContentLanguageSettings::loadByEntityTypeBundle('node', 'article')
->setLanguageAlterable(TRUE)
->save();
$response = $this->request('PATCH', Url::fromUri('base:/ca/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$this->assertSame(500, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame('The translation language cannot be changed (ca).', $document['errors'][0]['detail']);
// Changing the langcode of the default ('en') translation is possible:
// first verify that it currently is 'en', then change it to 'ca-fr', and
// verify that the title is unchanged, but the langcode is updated.
$response = $this->request('GET', Url::fromUri('base:/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$this->assertSame(200, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame($node->getTitle(), $document['data']['attributes']['title']);
$this->assertSame('en', $document['data']['attributes']['langcode']);
$response = $this->request('PATCH', Url::fromUri('base:/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$this->assertSame(200, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame($node->getTitle(), $document['data']['attributes']['title']);
$this->assertSame('ca-fr', $document['data']['attributes']['langcode']);
// Finally: assert the postcondition of all installed languages.
// - When GETting the 'en' translation, we get 'ca-fr', since the 'en'
// translation doesn't exist anymore.
$response = $this->request('GET', Url::fromUri('base:/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$document = Json::decode((string) $response->getBody());
$this->assertSame('ca-fr', $document['data']['attributes']['langcode']);
$this->assertSame($node->getTitle(), $document['data']['attributes']['title']);
// - When GETting the 'ca' translation, we still get the 'ca' one.
$response = $this->request('GET', Url::fromUri('base:/ca/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$document = Json::decode((string) $response->getBody());
$this->assertSame('ca', $document['data']['attributes']['langcode']);
$this->assertSame($node->getTitle() . ' (ca) UPDATED', $document['data']['attributes']['title']);
// - When GETting the 'ca-fr' translation, we now get the default
// translation.
$response = $this->request('GET', Url::fromUri('base:/ca-fr/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$document = Json::decode((string) $response->getBody());
$this->assertSame('ca-fr', $document['data']['attributes']['langcode']);
$this->assertSame($node->getTitle(), $document['data']['attributes']['title']);
}
/**
* Tests updating a translation fallback.
*/
public function testPatchTranslationFallback() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$node = $this->nodes[0];
$uuid = $node->uuid();
// Assert the precondition: 'ca-fr' falls back to the 'ca' translation which
// has a different title.
$document = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid));
$document_ca = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $uuid));
$document_cafr = Json::decode($this->drupalGet('/ca-fr/jsonapi/node/article/' . $uuid));
$this->assertSame('en', $document['data']['attributes']['langcode']);
$this->assertSame('ca', $document_ca['data']['attributes']['langcode']);
$this->assertSame('ca', $document_cafr['data']['attributes']['langcode']);
$this->assertSame($node->getTitle(), $document['data']['attributes']['title']);
$this->assertSame($node->getTitle() . ' (ca)', $document_ca['data']['attributes']['title']);
$this->assertSame($node->getTitle() . ' (ca)', $document_cafr['data']['attributes']['title']);
// PATCH the 'ca-fr' translation.
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [
'bypass node access',
]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options[RequestOptions::BODY] = Json::encode([
'data' => [
'type' => 'node--article',
'id' => $uuid,
'attributes' => [
'title' => $document_cafr['data']['attributes']['title'] . ' UPDATED',
],
],
]);
$response = $this->request('PATCH', Url::fromUri('base:/ca-fr/jsonapi/node/article/' . $this->nodes[0]->uuid()), $request_options);
$this->assertSame(405, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame('The requested translation of the resource object does not exist, instead modify one of the translations that do exist: ca, en.', $document['errors'][0]['detail']);
}
/**
* Tests creating a translation.
*/
public function testPostTranslation() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [
'bypass node access',
]);
$title = 'Llamas FTW (ca)';
$request_document = [
'data' => [
'type' => 'node--article',
'attributes' => [
'title' => $title,
'langcode' => 'ca',
],
],
];
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
// Specifying a langcode is forbidden by language_entity_field_access().
$request_options[RequestOptions::BODY] = Json::encode($request_document);
$response = $this->request('POST', Url::fromUri('base:/ca/jsonapi/node/article/'), $request_options);
$this->assertSame(403, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame('The current user is not allowed to POST the selected field (langcode).', $document['errors'][0]['detail']);
// Omitting a langcode results in an entity in 'en': the default language of
// the site.
unset($request_document['data']['attributes']['langcode']);
$request_options[RequestOptions::BODY] = Json::encode($request_document);
$response = $this->request('POST', Url::fromUri('base:/ca/jsonapi/node/article/'), $request_options);
$this->assertSame(201, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame($title, $document['data']['attributes']['title']);
$this->assertSame('en', $document['data']['attributes']['langcode']);
$this->assertSame(['en'], array_keys(Node::load($document['data']['attributes']['drupal_internal__nid'])->getTranslationLanguages()));
// Specifying a langcode is allowed once configured to be alterable. Now an
// entity can be created with the specified langcode.
ContentLanguageSettings::loadByEntityTypeBundle('node', 'article')
->setLanguageAlterable(TRUE)
->save();
$request_document['data']['attributes']['langcode'] = 'ca';
$request_options[RequestOptions::BODY] = Json::encode($request_document);
$response = $this->request('POST', Url::fromUri('base:/ca/jsonapi/node/article/'), $request_options);
$this->assertSame(201, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame($title, $document['data']['attributes']['title']);
$this->assertSame('ca', $document['data']['attributes']['langcode']);
$this->assertSame(['ca'], array_keys(Node::load($document['data']['attributes']['drupal_internal__nid'])->getTranslationLanguages()));
// Same request, but sent to the URL without the language prefix.
$response = $this->request('POST', Url::fromUri('base:/jsonapi/node/article/'), $request_options);
$this->assertSame(201, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame($title, $document['data']['attributes']['title']);
$this->assertSame('ca', $document['data']['attributes']['langcode']);
$this->assertSame(['ca'], array_keys(Node::load($document['data']['attributes']['drupal_internal__nid'])->getTranslationLanguages()));
}
/**
* Tests deleting multilingual content.
*/
public function testDeleteMultilingual() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [
'bypass node access',
]);
$response = $this->request('DELETE', Url::fromUri('base:/ca/jsonapi/node/article/' . $this->nodes[0]->uuid()), []);
$this->assertSame(405, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame('Deleting a resource object translation is not yet supported. See https://www.drupal.org/docs/8/modules/jsonapi/translations.', $document['errors'][0]['detail']);
$response = $this->request('DELETE', Url::fromUri('base:/ca-fr/jsonapi/node/article/' . $this->nodes[0]->uuid()), []);
$this->assertSame(405, $response->getStatusCode());
$document = Json::decode((string) $response->getBody());
$this->assertSame('Deleting a resource object translation is not yet supported. See https://www.drupal.org/docs/8/modules/jsonapi/translations.', $document['errors'][0]['detail']);
$response = $this->request('DELETE', Url::fromUri('base:/jsonapi/node/article/' . $this->nodes[0]->uuid()), []);
$this->assertSame(204, $response->getStatusCode());
$this->assertNull(Node::load($this->nodes[0]->id()));
}
}
@@ -0,0 +1,890 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\jsonapi\Query\OffsetPage;
use Drupal\node\Entity\Node;
/**
* General functional test class.
*
* @group jsonapi
*
* @internal
*/
class JsonApiFunctionalTest extends JsonApiFunctionalTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'basic_auth',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Test the GET method.
*/
public function testRead() {
$this->createDefaultContent(61, 5, TRUE, TRUE, static::IS_NOT_MULTILINGUAL, FALSE);
// Unpublish the last entity, so we can check access.
$this->nodes[60]->setUnpublished()->save();
// Different databases have different sort orders, so a sort is required so
// test expectations do not need to vary per database.
$default_sort = ['sort' => 'drupal_internal__nid'];
// 0. HEAD request allows a client to verify that JSON:API is installed.
$this->httpClient->request('HEAD', $this->buildUrl('/jsonapi/node/article'));
$this->assertSession()->statusCodeEquals(200);
// 1. Load all articles (1st page).
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(OffsetPage::SIZE_MAX, $collection_output['data']);
$this->assertSession()
->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
// 2. Load all articles (Offset 3).
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['page' => ['offset' => 3]] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data']));
$this->assertStringContainsString('page%5Boffset%5D=53', $collection_output['links']['next']['href']);
// 3. Load all articles (1st page, 2 items)
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['page' => ['limit' => 2]] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(2, $collection_output['data']);
// 4. Load all articles (2nd page, 2 items).
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => [
'page' => [
'limit' => 2,
'offset' => 2,
],
] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(2, $collection_output['data']);
$this->assertStringContainsString('page%5Boffset%5D=4', $collection_output['links']['next']['href']);
// 5. Single article.
$uuid = $this->nodes[0]->uuid();
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid));
$this->assertSession()->statusCodeEquals(200);
$this->assertArrayHasKey('type', $single_output['data']);
$this->assertEquals($this->nodes[0]->getTitle(), $single_output['data']['attributes']['title']);
// 5.1 Single article with access denied because unauthenticated.
Json::decode($this->drupalGet('/jsonapi/node/article/' . $this->nodes[60]->uuid()));
$this->assertSession()->statusCodeEquals(401);
// 5.1 Single article with access denied while authenticated.
$this->drupalLogin($this->userCanViewProfiles);
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $this->nodes[60]->uuid()));
$this->assertSession()->statusCodeEquals(403);
$this->assertEquals('/data', $single_output['errors'][0]['source']['pointer']);
$this->drupalLogout();
// 6. Single relationship item.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/node_type'));
$this->assertSession()->statusCodeEquals(200);
$this->assertArrayHasKey('type', $single_output['data']);
$this->assertArrayNotHasKey('attributes', $single_output['data']);
$this->assertArrayHasKey('related', $single_output['links']);
// 7. Single relationship image.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/field_image'));
$this->assertSession()->statusCodeEquals(200);
$this->assertArrayHasKey('type', $single_output['data']);
$this->assertArrayNotHasKey('attributes', $single_output['data']);
$this->assertArrayHasKey('related', $single_output['links']);
// 8. Multiple relationship item.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/field_tags'));
$this->assertSession()->statusCodeEquals(200);
$this->assertArrayHasKey('type', $single_output['data'][0]);
$this->assertArrayNotHasKey('attributes', $single_output['data'][0]);
$this->assertArrayHasKey('related', $single_output['links']);
// 8b. Single related item, empty.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/field_heroless'));
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(NULL, $single_output['data']);
// 9. Related tags with includes.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/field_tags', [
'query' => ['include' => 'vid'],
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertEquals('taxonomy_term--tags', $single_output['data'][0]['type']);
$this->assertArrayNotHasKey('tid', $single_output['data'][0]['attributes']);
$this->assertStringContainsString(
'/taxonomy_term/tags/',
$single_output['data'][0]['links']['self']['href']
);
$this->assertEquals(
'taxonomy_vocabulary--taxonomy_vocabulary',
$single_output['included'][0]['type']
);
// 10. Single article with includes.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid, [
'query' => ['include' => 'uid,field_tags'],
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertEquals('node--article', $single_output['data']['type']);
$first_include = reset($single_output['included']);
$this->assertEquals(
'user--user',
$first_include['type']
);
$last_include = end($single_output['included']);
$this->assertEquals(
'taxonomy_term--tags',
$last_include['type']
);
// 10b. Single article with nested includes.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid, [
'query' => ['include' => 'field_tags,field_tags.vid'],
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertEquals('node--article', $single_output['data']['type']);
$first_include = reset($single_output['included']);
$this->assertEquals(
'taxonomy_term--tags',
$first_include['type']
);
$last_include = end($single_output['included']);
$this->assertEquals(
'taxonomy_vocabulary--taxonomy_vocabulary',
$last_include['type']
);
// 11. Includes with relationships.
$this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/uid');
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/uid', [
'query' => ['include' => 'uid'],
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertEquals('user--user', $single_output['data']['type']);
$this->assertArrayHasKey('related', $single_output['links']);
$this->assertArrayHasKey('included', $single_output);
$first_include = reset($single_output['included']);
$this->assertEquals(
'user--user',
$first_include['type']
);
$this->assertFalse(empty($first_include['attributes']));
$this->assertTrue(empty($first_include['attributes']['mail']));
$this->assertTrue(empty($first_include['attributes']['pass']));
// 12. Collection with one access denied.
$this->nodes[1]->set('status', FALSE);
$this->nodes[1]->save();
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['page' => ['limit' => 2]] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(1, $single_output['data']);
$non_help_links = array_filter(array_keys($single_output['meta']['omitted']['links']), function ($key) {
return $key !== 'help';
});
$this->assertCount(1, $non_help_links);
$link_keys = array_keys($single_output['meta']['omitted']['links']);
$this->assertSame('help', reset($link_keys));
$this->assertRegExp('/^item--[a-zA-Z0-9]{7}$/', next($link_keys));
$this->nodes[1]->set('status', TRUE);
$this->nodes[1]->save();
// 13. Test filtering when using short syntax.
$filter = [
'uid.id' => ['value' => $this->user->uuid()],
'field_tags.id' => ['value' => $this->tags[0]->uuid()],
];
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter, 'include' => 'uid,field_tags'],
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThan(0, count($single_output['data']));
// 14. Test filtering when using long syntax.
$filter = [
'and_group' => ['group' => ['conjunction' => 'AND']],
'filter_user' => [
'condition' => [
'path' => 'uid.id',
'value' => $this->user->uuid(),
'memberOf' => 'and_group',
],
],
'filter_tags' => [
'condition' => [
'path' => 'field_tags.id',
'value' => $this->tags[0]->uuid(),
'memberOf' => 'and_group',
],
],
];
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter, 'include' => 'uid,field_tags'],
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThan(0, count($single_output['data']));
// 15. Test filtering when using invalid syntax.
$filter = [
'and_group' => ['group' => ['conjunction' => 'AND']],
'filter_user' => [
'condition' => [
'name-with-a-typo' => 'uid.id',
'value' => $this->user->uuid(),
'memberOf' => 'and_group',
],
],
];
$this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]);
$this->assertSession()->statusCodeEquals(400);
// 16. Test filtering on the same field.
$filter = [
'or_group' => ['group' => ['conjunction' => 'OR']],
'filter_tags_1' => [
'condition' => [
'path' => 'field_tags.id',
'value' => $this->tags[0]->uuid(),
'memberOf' => 'or_group',
],
],
'filter_tags_2' => [
'condition' => [
'path' => 'field_tags.id',
'value' => $this->tags[1]->uuid(),
'memberOf' => 'or_group',
],
],
];
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter, 'include' => 'field_tags'] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThanOrEqual(2, count($single_output['included']));
// 17. Single user (check fields lacking 'view' access).
$user_url = Url::fromRoute('jsonapi.user--user.individual', [
'entity' => $this->user->uuid(),
]);
$response = $this->request('GET', $user_url, [
'auth' => [
$this->userCanViewProfiles->getAccountName(),
$this->userCanViewProfiles->pass_raw,
],
]);
$single_output = Json::decode($response->getBody()->__toString());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('user--user', $single_output['data']['type']);
$this->assertEquals($this->user->get('name')->value, $single_output['data']['attributes']['name']);
$this->assertTrue(empty($single_output['data']['attributes']['mail']));
$this->assertTrue(empty($single_output['data']['attributes']['pass']));
// 18. Test filtering on the column of a link.
$filter = [
'linkUri' => [
'condition' => [
'path' => 'field_link.uri',
'value' => 'https://',
'operator' => 'STARTS_WITH',
],
],
];
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThanOrEqual(1, count($single_output['data']));
// 19. Test non-existing route without 'Accept' header.
$this->drupalGet('/jsonapi/node/article/broccoli');
$this->assertSession()->statusCodeEquals(404);
// Even without the 'Accept' header the 404 error is formatted as JSON:API.
$this->assertSession()->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
// 20. Test non-existing route with 'Accept' header.
$single_output = Json::decode($this->drupalGet('/jsonapi/node/article/broccoli', [], [
'Accept' => 'application/vnd.api+json',
]));
$this->assertEquals(404, $single_output['errors'][0]['status']);
$this->assertSession()->statusCodeEquals(404);
// With the 'Accept' header we can know we want the 404 error formatted as
// JSON:API.
$this->assertSession()->responseHeaderContains('Content-Type', 'application/vnd.api+json');
// 22. Test sort criteria on multiple fields: both ASC.
$output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => [
'page[limit]' => 6,
'sort' => 'field_sort1,field_sort2',
],
]));
$output_uuids = array_map(function ($result) {
return $result['id'];
}, $output['data']);
$this->assertCount(6, $output_uuids);
$this->assertSame([
Node::load(5)->uuid(),
Node::load(4)->uuid(),
Node::load(3)->uuid(),
Node::load(2)->uuid(),
Node::load(1)->uuid(),
Node::load(10)->uuid(),
], $output_uuids);
// 23. Test sort criteria on multiple fields: first ASC, second DESC.
$output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => [
'page[limit]' => 6,
'sort' => 'field_sort1,-field_sort2',
],
]));
$output_uuids = array_map(function ($result) {
return $result['id'];
}, $output['data']);
$this->assertCount(6, $output_uuids);
$this->assertSame([
Node::load(1)->uuid(),
Node::load(2)->uuid(),
Node::load(3)->uuid(),
Node::load(4)->uuid(),
Node::load(5)->uuid(),
Node::load(6)->uuid(),
], $output_uuids);
// 24. Test sort criteria on multiple fields: first DESC, second ASC.
$output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => [
'page[limit]' => 6,
'sort' => '-field_sort1,field_sort2',
],
]));
$output_uuids = array_map(function ($result) {
return $result['id'];
}, $output['data']);
$this->assertCount(5, $output_uuids);
$this->assertCount(2, $output['meta']['omitted']['links']);
$this->assertSame([
Node::load(60)->uuid(),
Node::load(59)->uuid(),
Node::load(58)->uuid(),
Node::load(57)->uuid(),
Node::load(56)->uuid(),
], $output_uuids);
// 25. Test sort criteria on multiple fields: both DESC.
$output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => [
'page[limit]' => 6,
'sort' => '-field_sort1,-field_sort2',
],
]));
$output_uuids = array_map(function ($result) {
return $result['id'];
}, $output['data']);
$this->assertCount(5, $output_uuids);
$this->assertCount(2, $output['meta']['omitted']['links']);
$this->assertSame([
Node::load(56)->uuid(),
Node::load(57)->uuid(),
Node::load(58)->uuid(),
Node::load(59)->uuid(),
Node::load(60)->uuid(),
], $output_uuids);
// 25. Test collection count.
$this->container->get('module_installer')->install(['jsonapi_test_collection_count']);
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article'));
$this->assertSession()->statusCodeEquals(200);
$this->assertEquals(61, $collection_output['meta']['count']);
$this->container->get('module_installer')->uninstall(['jsonapi_test_collection_count']);
// Test documentation filtering examples.
// 1. Only get published nodes.
$filter = [
'status-filter' => [
'condition' => [
'path' => 'status',
'value' => 1,
],
],
];
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
// 2. Nested Filters: Get nodes created by user admin.
$filter = [
'name-filter' => [
'condition' => [
'path' => 'uid.name',
'value' => $this->user->getAccountName(),
],
],
];
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
// 3. Filtering with arrays: Get nodes created by users [admin, john].
$filter = [
'name-filter' => [
'condition' => [
'path' => 'uid.name',
'operator' => 'IN',
'value' => [
$this->user->getAccountName(),
$this->getRandomGenerator()->name(),
],
],
],
];
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
// 4. Grouping filters: Get nodes that are published and create by admin.
$filter = [
'and-group' => [
'group' => [
'conjunction' => 'AND',
],
],
'name-filter' => [
'condition' => [
'path' => 'uid.name',
'value' => $this->user->getAccountName(),
'memberOf' => 'and-group',
],
],
'status-filter' => [
'condition' => [
'path' => 'status',
'value' => 1,
'memberOf' => 'and-group',
],
],
];
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
// 5. Grouping grouped filters: Get nodes that are promoted or sticky and
// created by admin.
$filter = [
'and-group' => [
'group' => [
'conjunction' => 'AND',
],
],
'or-group' => [
'group' => [
'conjunction' => 'OR',
'memberOf' => 'and-group',
],
],
'admin-filter' => [
'condition' => [
'path' => 'uid.name',
'value' => $this->user->getAccountName(),
'memberOf' => 'and-group',
],
],
'sticky-filter' => [
'condition' => [
'path' => 'sticky',
'value' => 1,
'memberOf' => 'or-group',
],
],
'promote-filter' => [
'condition' => [
'path' => 'promote',
'value' => 0,
'memberOf' => 'or-group',
],
],
];
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
'query' => ['filter' => $filter] + $default_sort,
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(0, $collection_output['data']);
}
/**
* Test the GET method on articles referencing the same tag twice.
*/
public function testReferencingTwiceRead() {
$this->createDefaultContent(1, 1, FALSE, FALSE, static::IS_NOT_MULTILINGUAL, TRUE);
// 1. Load all articles (1st page).
$collection_output = Json::decode($this->drupalGet('/jsonapi/node/article'));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(1, $collection_output['data']);
$this->assertSession()
->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
}
/**
* Test POST, PATCH and DELETE.
*/
public function testWrite() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$this->createDefaultContent(0, 3, FALSE, FALSE, static::IS_NOT_MULTILINGUAL, FALSE);
// 1. Successful post.
$collection_url = Url::fromRoute('jsonapi.node--article.collection.post');
$body = [
'data' => [
'type' => 'node--article',
'attributes' => [
'langcode' => 'en',
'title' => 'My custom title',
'default_langcode' => '1',
'body' => [
'value' => 'Custom value',
'format' => 'plain_text',
'summary' => 'Custom summary',
],
],
'relationships' => [
'field_tags' => [
'data' => [
[
'type' => 'taxonomy_term--tags',
'id' => $this->tags[0]->uuid(),
],
[
'type' => 'taxonomy_term--tags',
'id' => $this->tags[1]->uuid(),
],
],
],
],
],
];
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(201, $response->getStatusCode());
$this->assertArrayNotHasKey('uuid', $created_response['data']['attributes']);
$uuid = $created_response['data']['id'];
$this->assertCount(2, $created_response['data']['relationships']['field_tags']['data']);
$this->assertEquals($created_response['data']['links']['self']['href'], $response->getHeader('Location')[0]);
// 2. Authorization error.
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($body),
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(401, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertEquals('Unauthorized', $created_response['errors'][0]['title']);
// 2.1 Authorization error with a user without create permissions.
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($body),
'auth' => [$this->userCanViewProfiles->getAccountName(), $this->userCanViewProfiles->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(403, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertEquals('Forbidden', $created_response['errors'][0]['title']);
// 3. Missing Content-Type error.
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Accept' => 'application/vnd.api+json'],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(415, $response->getStatusCode());
// 4. Article with a duplicate ID.
$invalid_body = $body;
$invalid_body['data']['id'] = Node::load(1)->uuid();
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($invalid_body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(409, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertEquals('Conflict', $created_response['errors'][0]['title']);
// 5. Article with wrong reference UUIDs for tags.
$body_invalid_tags = $body;
$body_invalid_tags['data']['relationships']['field_tags']['data'][0]['id'] = 'lorem';
$body_invalid_tags['data']['relationships']['field_tags']['data'][1]['id'] = 'ipsum';
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($body_invalid_tags),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(404, $response->getStatusCode());
// 6. Decoding error.
$response = $this->request('POST', $collection_url, [
'body' => '{"bad json",,,}',
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Content-Type' => 'application/vnd.api+json',
'Accept' => 'application/vnd.api+json',
],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(400, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertEquals('Bad Request', $created_response['errors'][0]['title']);
// 6.1 Denormalizing error.
$response = $this->request('POST', $collection_url, [
'body' => '{"data":{"type":"something"},"valid yet nonsensical json":[]}',
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Content-Type' => 'application/vnd.api+json',
'Accept' => 'application/vnd.api+json',
],
]);
$created_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(422, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertEquals('Unprocessable Entity', $created_response['errors'][0]['title']);
// 6.2 Relationships are not included in "data".
$malformed_body = $body;
unset($malformed_body['data']['relationships']);
$malformed_body['relationships'] = $body['data']['relationships'];
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($malformed_body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
],
]);
$created_response = Json::decode((string) $response->getBody());
$this->assertSame(400, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertSame("Bad Request", $created_response['errors'][0]['title']);
$this->assertSame("Found \"relationships\" within the document's top level. The \"relationships\" key must be within resource object.", $created_response['errors'][0]['detail']);
// 6.2 "type" not included in "data".
$missing_type = $body;
unset($missing_type['data']['type']);
$response = $this->request('POST', $collection_url, [
'body' => Json::encode($missing_type),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
],
]);
$created_response = Json::decode((string) $response->getBody());
$this->assertSame(400, $response->getStatusCode());
$this->assertNotEmpty($created_response['errors']);
$this->assertSame("Bad Request", $created_response['errors'][0]['title']);
$this->assertSame("Resource object must include a \"type\".", $created_response['errors'][0]['detail']);
// 7. Successful PATCH.
$body = [
'data' => [
'id' => $uuid,
'type' => 'node--article',
'attributes' => ['title' => 'My updated title'],
],
];
$individual_url = Url::fromRoute('jsonapi.node--article.individual', [
'entity' => $uuid,
]);
$response = $this->request('PATCH', $individual_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$updated_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('My updated title', $updated_response['data']['attributes']['title']);
// 7.1 Unsuccessful PATCH due to access restrictions.
$body = [
'data' => [
'id' => $uuid,
'type' => 'node--article',
'attributes' => ['title' => 'My updated title'],
],
];
$individual_url = Url::fromRoute('jsonapi.node--article.individual', [
'entity' => $uuid,
]);
$response = $this->request('PATCH', $individual_url, [
'body' => Json::encode($body),
'auth' => [$this->userCanViewProfiles->getAccountName(), $this->userCanViewProfiles->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$this->assertEquals(403, $response->getStatusCode());
// 8. Field access forbidden check.
$body = [
'data' => [
'id' => $uuid,
'type' => 'node--article',
'attributes' => [
'title' => 'My updated title',
'status' => 0,
],
],
];
$response = $this->request('PATCH', $individual_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$updated_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(403, $response->getStatusCode());
$this->assertEquals("The current user is not allowed to PATCH the selected field (status). The 'administer nodes' permission is required.",
$updated_response['errors'][0]['detail']);
$node = \Drupal::service('entity.repository')->loadEntityByUuid('node', $uuid);
$this->assertEquals(1, $node->get('status')->value, 'Node status was not changed.');
// 9. Successful POST to related endpoint.
$body = [
'data' => [
[
'id' => $this->tags[2]->uuid(),
'type' => 'taxonomy_term--tags',
],
],
];
$relationship_url = Url::fromRoute('jsonapi.node--article.field_tags.relationship.post', [
'entity' => $uuid,
]);
$response = $this->request('POST', $relationship_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$updated_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(200, $response->getStatusCode());
$this->assertCount(3, $updated_response['data']);
$this->assertEquals('taxonomy_term--tags', $updated_response['data'][2]['type']);
$this->assertEquals($this->tags[2]->uuid(), $updated_response['data'][2]['id']);
// 10. Successful PATCH to related endpoint.
$body = [
'data' => [
[
'id' => $this->tags[1]->uuid(),
'type' => 'taxonomy_term--tags',
],
],
];
$response = $this->request('PATCH', $relationship_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$this->assertEquals(204, $response->getStatusCode());
$this->assertEmpty($response->getBody()->__toString());
// 11. Successful DELETE to related endpoint.
$response = $this->request('DELETE', $relationship_url, [
// Send a request with no body.
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Content-Type' => 'application/vnd.api+json',
'Accept' => 'application/vnd.api+json',
],
]);
$updated_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(
'You need to provide a body for DELETE operations on a relationship (field_tags).',
$updated_response['errors'][0]['detail']
);
$this->assertEquals(400, $response->getStatusCode());
$response = $this->request('DELETE', $relationship_url, [
// Send a request with no authentication.
'body' => Json::encode($body),
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$this->assertEquals(401, $response->getStatusCode());
$response = $this->request('DELETE', $relationship_url, [
// Remove the existing relationship item.
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => ['Content-Type' => 'application/vnd.api+json'],
]);
$this->assertEquals(204, $response->getStatusCode());
$this->assertEmpty($response->getBody()->__toString());
// 12. PATCH with invalid title and body format.
$body = [
'data' => [
'id' => $uuid,
'type' => 'node--article',
'attributes' => [
'title' => '',
'body' => [
'value' => 'Custom value',
'format' => 'invalid_format',
'summary' => 'Custom summary',
],
],
],
];
$response = $this->request('PATCH', $individual_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Content-Type' => 'application/vnd.api+json',
'Accept' => 'application/vnd.api+json',
],
]);
$updated_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(422, $response->getStatusCode());
$this->assertCount(2, $updated_response['errors']);
for ($i = 0; $i < 2; $i++) {
$this->assertEquals("Unprocessable Entity", $updated_response['errors'][$i]['title']);
$this->assertEquals(422, $updated_response['errors'][$i]['status']);
}
$this->assertEquals("title: This value should not be null.", $updated_response['errors'][0]['detail']);
$this->assertEquals("body.0.format: The value you selected is not a valid choice.", $updated_response['errors'][1]['detail']);
$this->assertEquals("/data/attributes/title", $updated_response['errors'][0]['source']['pointer']);
$this->assertEquals("/data/attributes/body/format", $updated_response['errors'][1]['source']['pointer']);
// 13. PATCH with field that doesn't exist on Entity.
$body = [
'data' => [
'id' => $uuid,
'type' => 'node--article',
'attributes' => [
'field_that_does_not_exist' => 'foobar',
],
],
];
$response = $this->request('PATCH', $individual_url, [
'body' => Json::encode($body),
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
'headers' => [
'Content-Type' => 'application/vnd.api+json',
'Accept' => 'application/vnd.api+json',
],
]);
$updated_response = Json::decode($response->getBody()->__toString());
$this->assertEquals(422, $response->getStatusCode());
$this->assertEquals("The attribute field_that_does_not_exist does not exist on the node--article resource type.",
$updated_response['errors']['0']['detail']);
// 14. Successful DELETE.
$response = $this->request('DELETE', $individual_url, [
'auth' => [$this->user->getAccountName(), $this->user->pass_raw],
]);
$this->assertEquals(204, $response->getStatusCode());
$response = $this->request('GET', $individual_url, []);
$this->assertEquals(404, $response->getStatusCode());
}
}
@@ -0,0 +1,336 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
/**
* Provides helper methods for the JSON:API module's functional tests.
*
* @internal
*/
abstract class JsonApiFunctionalTestBase extends BrowserTestBase {
use EntityReferenceTestTrait;
use ImageFieldCreationTrait;
const IS_MULTILINGUAL = TRUE;
const IS_NOT_MULTILINGUAL = FALSE;
/**
* {@inheritdoc}
*/
public static $modules = [
'jsonapi',
'serialization',
'node',
'image',
'taxonomy',
'link',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Test user.
*
* @var \Drupal\user\Entity\User
*/
protected $user;
/**
* Test user with access to view profiles.
*
* @var \Drupal\user\Entity\User
*/
protected $userCanViewProfiles;
/**
* Test nodes.
*
* @var \Drupal\node\Entity\Node[]
*/
protected $nodes = [];
/**
* Test taxonomy terms.
*
* @var \Drupal\taxonomy\Entity\Term[]
*/
protected $tags = [];
/**
* Test files.
*
* @var \Drupal\file\Entity\File[]
*/
protected $files = [];
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $httpClient;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Set up a HTTP client that accepts relative URLs.
$this->httpClient = $this->container->get('http_client_factory')
->fromOptions(['base_uri' => $this->baseUrl]);
// Create Basic page and Article node types.
if ($this->profile != 'standard') {
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
// Setup vocabulary.
Vocabulary::create([
'vid' => 'tags',
'name' => 'Tags',
])->save();
// Add tags and field_image to the article.
$this->createEntityReferenceField(
'node',
'article',
'field_tags',
'Tags',
'taxonomy_term',
'default',
[
'target_bundles' => [
'tags' => 'tags',
],
'auto_create' => TRUE,
],
FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
);
$this->createImageField('field_image', 'article');
$this->createImageField('field_heroless', 'article');
}
FieldStorageConfig::create([
'field_name' => 'field_link',
'entity_type' => 'node',
'type' => 'link',
'settings' => [],
'cardinality' => 1,
])->save();
$field_config = FieldConfig::create([
'field_name' => 'field_link',
'label' => 'Link',
'entity_type' => 'node',
'bundle' => 'article',
'required' => FALSE,
'settings' => [],
'description' => '',
]);
$field_config->save();
// Field for testing sorting.
FieldStorageConfig::create([
'field_name' => 'field_sort1',
'entity_type' => 'node',
'type' => 'integer',
])->save();
FieldConfig::create([
'field_name' => 'field_sort1',
'entity_type' => 'node',
'bundle' => 'article',
])->save();
// Another field for testing sorting.
FieldStorageConfig::create([
'field_name' => 'field_sort2',
'entity_type' => 'node',
'type' => 'integer',
])->save();
FieldConfig::create([
'field_name' => 'field_sort2',
'entity_type' => 'node',
'bundle' => 'article',
])->save();
$this->user = $this->drupalCreateUser([
'create article content',
'edit any article content',
'delete any article content',
]);
// Create a user that can.
$this->userCanViewProfiles = $this->drupalCreateUser([
'access user profiles',
]);
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [
'access user profiles',
'administer taxonomy',
]);
drupal_flush_all_caches();
}
/**
* Performs a HTTP request. Wraps the Guzzle HTTP client.
*
* Why wrap the Guzzle HTTP client? Because any error response is returned via
* an exception, which would make the tests unnecessarily complex to read.
*
* @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
* The request response.
*
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @see \GuzzleHttp\ClientInterface::request
*/
protected function request($method, Url $url, array $request_options) {
try {
$response = $this->httpClient->request($method, $url->toString(), $request_options);
}
catch (ClientException $e) {
$response = $e->getResponse();
}
catch (ServerException $e) {
$response = $e->getResponse();
}
return $response;
}
/**
* Creates default content to test the API.
*
* @param int $num_articles
* Number of articles to create.
* @param int $num_tags
* Number of tags to create.
* @param bool $article_has_image
* Set to TRUE if you want to add an image to the generated articles.
* @param bool $article_has_link
* Set to TRUE if you want to add a link to the generated articles.
* @param bool $is_multilingual
* (optional) Set to TRUE if you want to enable multilingual content.
* @param bool $referencing_twice
* (optional) Set to TRUE if you want articles to reference the same tag
* twice.
*/
protected function createDefaultContent($num_articles, $num_tags, $article_has_image, $article_has_link, $is_multilingual, $referencing_twice = FALSE) {
$random = $this->getRandomGenerator();
for ($created_tags = 0; $created_tags < $num_tags; $created_tags++) {
$term = Term::create([
'vid' => 'tags',
'name' => $random->name(),
]);
if ($is_multilingual) {
$term->addTranslation('ca', ['name' => $term->getName() . ' (ca)']);
}
$term->save();
$this->tags[] = $term;
}
for ($created_nodes = 0; $created_nodes < $num_articles; $created_nodes++) {
$values = [
'uid' => ['target_id' => $this->user->id()],
'type' => 'article',
];
if ($referencing_twice) {
$values['field_tags'] = [
['target_id' => 1],
['target_id' => 1],
];
}
else {
// Get N random tags.
$selected_tags = mt_rand(1, $num_tags);
$tags = [];
while (count($tags) < $selected_tags) {
$tags[] = mt_rand(1, $num_tags);
$tags = array_unique($tags);
}
$values['field_tags'] = array_map(function ($tag) {
return ['target_id' => $tag];
}, $tags);
}
if ($article_has_image) {
$file = File::create([
'uri' => 'vfs://' . $random->name() . '.png',
]);
$file->setPermanent();
$file->save();
$this->files[] = $file;
$values['field_image'] = ['target_id' => $file->id(), 'alt' => 'alt text'];
}
if ($article_has_link) {
$values['field_link'] = [
'title' => $this->getRandomGenerator()->name(),
'uri' => sprintf(
'%s://%s.%s',
'http' . (mt_rand(0, 2) > 1 ? '' : 's'),
$this->getRandomGenerator()->name(),
'org'
),
];
}
// Create values for the sort fields, to allow for testing complex
// sorting:
// - field_sort1 increments every 5 articles, starting at zero
// - field_sort2 decreases every article, ending at zero.
$values['field_sort1'] = ['value' => floor($created_nodes / 5)];
$values['field_sort2'] = ['value' => $num_articles - $created_nodes];
$node = $this->createNode($values);
if ($is_multilingual === static::IS_MULTILINGUAL) {
$values['title'] = $node->getTitle() . ' (ca)';
$values['field_image']['alt'] = 'alt text (ca)';
$node->addTranslation('ca', $values);
}
$node->save();
$this->nodes[] = $node;
}
if ($article_has_link) {
// Make sure that there is at least 1 https link for ::testRead() #19.
$this->nodes[0]->field_link = [
'title' => 'Drupal',
'uri' => 'https://drupal.org',
];
$this->nodes[0]->save();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Behat\Mink\Driver\BrowserKitDriver;
use Drupal\Core\Url;
use GuzzleHttp\RequestOptions;
/**
* Boilerplate for JSON:API Functional tests' HTTP requests.
*
* @internal
*/
trait JsonApiRequestTestTrait {
/**
* 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.
*
* @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
* The response.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function request($method, Url $url, array $request_options) {
$this->refreshVariables();
$request_options[RequestOptions::HTTP_ERRORS] = FALSE;
$request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;
$request_options = $this->decorateWithXdebugCookie($request_options);
$client = $this->getSession()->getDriver()->getClient()->getClient();
return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
}
/**
* 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;
}
}
@@ -0,0 +1,400 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Drupal\media\Entity\Media;
use Drupal\media\Entity\MediaType;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use Drupal\user\Entity\User;
/**
* JSON:API integration test for the "Media" content entity type.
*
* @group jsonapi
*/
class MediaTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['media'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'media';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'media--camelids';
/**
* {@inheritdoc}
*/
protected static $resourceTypeIsVersionable = TRUE;
/**
* {@inheritdoc}
*
* @var \Drupal\media\MediaInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'changed' => NULL,
];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['view media']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['create camelids media', 'access content']);
break;
case 'PATCH':
$this->grantPermissionsToTestedRole(['edit any camelids media']);
// @todo Remove this in https://www.drupal.org/node/2824851.
$this->grantPermissionsToTestedRole(['access content']);
break;
case 'DELETE':
$this->grantPermissionsToTestedRole(['delete any camelids media']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function setUpRevisionAuthorization($method) {
parent::setUpRevisionAuthorization($method);
$this->grantPermissionsToTestedRole(['view all media revisions']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
if (!MediaType::load('camelids')) {
// Create a "Camelids" media type.
$media_type = MediaType::create([
'name' => 'Camelids',
'id' => 'camelids',
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
'source' => 'file',
]);
$media_type->save();
// Create the source field.
$source_field = $media_type->getSource()->createSourceField($media_type);
$source_field->getFieldStorageDefinition()->save();
$source_field->save();
$media_type
->set('source_configuration', [
'source_field' => $source_field->getName(),
])
->save();
}
// Create a file to upload.
$file = File::create([
'uri' => 'public://llama.txt',
]);
$file->setPermanent();
$file->save();
// @see \Drupal\Tests\jsonapi\Functional\MediaTest::testPostIndividual()
$post_file = File::create([
'uri' => 'public://llama2.txt',
]);
$post_file->setPermanent();
$post_file->save();
// Create a "Llama" media item.
$media = Media::create([
'bundle' => 'camelids',
'field_media_file' => [
'target_id' => $file->id(),
],
]);
$media
->setName('Llama')
->setPublished()
->setCreatedTime(123456789)
->setOwnerId($this->account->id())
->setRevisionUserId($this->account->id())
->save();
return $media;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$file = File::load(1);
$thumbnail = File::load(3);
$author = User::load($this->entity->getOwnerId());
$base_url = Url::fromUri('base:/jsonapi/media/camelids/' . $this->entity->uuid())->setAbsolute();
$self_url = clone $base_url;
$version_identifier = 'id:' . $this->entity->getRevisionId();
$self_url = $self_url->setOption('query', ['resourceVersion' => $version_identifier]);
$version_query_string = '?resourceVersion=' . urlencode($version_identifier);
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $base_url->toString()],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'media--camelids',
'links' => [
'self' => ['href' => $self_url->toString()],
],
'attributes' => [
'langcode' => 'en',
'name' => 'Llama',
'status' => TRUE,
'created' => '1973-11-29T21:33:09+00:00',
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'revision_created' => (new \DateTime())->setTimestamp($this->entity->getRevisionCreationTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'default_langcode' => TRUE,
'revision_log_message' => NULL,
// @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
'revision_translation_affected' => TRUE,
'drupal_internal__mid' => 1,
'drupal_internal__vid' => 1,
],
'relationships' => [
'field_media_file' => [
'data' => [
'id' => $file->uuid(),
'meta' => [
'description' => NULL,
'display' => NULL,
],
'type' => 'file--file',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/field_media_file' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/field_media_file' . $version_query_string,
],
],
],
'thumbnail' => [
'data' => [
'id' => $thumbnail->uuid(),
'meta' => [
'alt' => '',
'width' => 180,
'height' => 180,
'title' => NULL,
],
'type' => 'file--file',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/thumbnail' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/thumbnail' . $version_query_string,
],
],
],
'bundle' => [
'data' => [
'id' => MediaType::load('camelids')->uuid(),
'type' => 'media_type--media_type',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/bundle' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/bundle' . $version_query_string,
],
],
],
'uid' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/uid' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/uid' . $version_query_string,
],
],
],
'revision_user' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/revision_user' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/revision_user' . $version_query_string,
],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
$file = File::load(2);
return [
'data' => [
'type' => 'media--camelids',
'attributes' => [
'name' => 'Dramallama',
],
'relationships' => [
'field_media_file' => [
'data' => [
'id' => $file->uuid(),
'meta' => [
'description' => 'This file is better!',
'display' => NULL,
],
'type' => 'file--file',
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET';
return "The 'view media' permission is required when the media item is published.";
case 'POST':
return "The following permissions are required: 'administer media' OR 'create media' OR 'create camelids media'.";
case 'PATCH':
return "The following permissions are required: 'update any media' OR 'update own media' OR 'camelids: edit any media' OR 'camelids: edit own media'.";
case 'DELETE':
return "The following permissions are required: 'delete any media' OR 'delete own media' OR 'camelids: delete any media' OR 'camelids: delete own media'.";
default:
return '';
}
}
/**
* {@inheritdoc}
*/
protected function getEditorialPermissions() {
return array_merge(parent::getEditorialPermissions(), ['view any unpublished content']);
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// @see \Drupal\media\MediaAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
->addCacheTags(['media:1']);
}
// @codingStandardsIgnoreStart
/**
* {@inheritdoc}
*/
public function testPostIndividual() {
// @todo Mimic \Drupal\Tests\rest\Functional\EntityResource\Media\MediaResourceTestBase::testPost()
// @todo Later, use https://www.drupal.org/project/jsonapi/issues/2958554 to upload files rather than the REST module.
parent::testPostIndividual();
}
// @codingStandardsIgnoreEnd
/**
* {@inheritdoc}
*/
protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) {
$data = parent::getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
switch ($relationship_field_name) {
case 'thumbnail':
$data['meta'] = [
'alt' => '',
'width' => 180,
'height' => 180,
'title' => NULL,
];
return $data;
case 'field_media_file':
$data['meta'] = [
'description' => NULL,
'display' => NULL,
];
return $data;
default:
return $data;
}
}
/**
* {@inheritdoc}
*
* @todo Remove this in https://www.drupal.org/node/2824851.
*/
protected function doTestRelationshipMutation(array $request_options) {
$this->grantPermissionsToTestedRole(['access content']);
parent::doTestRelationshipMutation($request_options);
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$this->doTestCollectionFilterAccessForPublishableEntities('name', 'view media', 'administer media');
}
}
@@ -0,0 +1,115 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\media\Entity\MediaType;
/**
* JSON:API integration test for the "MediaType" config entity type.
*
* @group jsonapi
*/
class MediaTypeTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['media'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'media_type';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'media_type--media_type';
/**
* {@inheritdoc}
*
* @var \Drupal\media\MediaTypeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer media types']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" media type.
$camelids = MediaType::create([
'name' => 'Camelids',
'id' => 'camelids',
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
'source' => 'file',
]);
$camelids->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/media_type/media_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'media_type--media_type',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
'field_map' => [],
'label' => NULL,
'langcode' => 'en',
'new_revision' => FALSE,
'queue_thumbnail_downloads' => FALSE,
'source' => 'file',
'source_configuration' => [
'source_field' => '',
],
'status' => TRUE,
'drupal_internal__id' => 'camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,241 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Url;
use Drupal\menu_link_content\Entity\MenuLinkContent;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "MenuLinkContent" content entity type.
*
* @group jsonapi
*/
class MenuLinkContentTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public static $modules = ['menu_link_content'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'menu_link_content';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'menu_link_content--menu_link_content';
/**
* {@inheritdoc}
*
* @var \Drupal\menu_link_content\MenuLinkContentInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'changed' => NULL,
];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer menu']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$menu_link = MenuLinkContent::create([
'id' => 'llama',
'title' => 'Llama Gabilondo',
'description' => 'Llama Gabilondo',
'link' => 'https://nl.wikipedia.org/wiki/Llama',
'weight' => 0,
'menu_name' => 'main',
]);
$menu_link->save();
return $menu_link;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/menu_link_content/menu_link_content/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'menu_link_content--menu_link_content',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'bundle' => 'menu_link_content',
'link' => [
'uri' => 'https://nl.wikipedia.org/wiki/Llama',
'title' => NULL,
'options' => [],
],
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'default_langcode' => TRUE,
'description' => 'Llama Gabilondo',
'enabled' => TRUE,
'expanded' => FALSE,
'external' => FALSE,
'langcode' => 'en',
'menu_name' => 'main',
'parent' => NULL,
'rediscover' => FALSE,
'title' => 'Llama Gabilondo',
'weight' => 0,
'drupal_internal__id' => 1,
'drupal_internal__revision_id' => 1,
'revision_created' => (new \DateTime())->setTimestamp($this->entity->getRevisionCreationTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'revision_log_message' => NULL,
// @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
'revision_translation_affected' => TRUE,
],
'relationships' => [
'revision_user' => [
'data' => NULL,
'links' => [
'related' => [
'href' => $self_url . '/revision_user',
],
'self' => [
'href' => $self_url . '/relationships/revision_user',
],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'menu_link_content--menu_link_content',
'attributes' => [
'title' => 'Dramallama',
'link' => [
'uri' => 'http://www.urbandictionary.com/define.php?term=drama%20llama',
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'DELETE':
return "The 'administer menu' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
public function testRelated() {
$this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$this->doTestCollectionFilterAccessBasedOnPermissions('title', 'administer menu');
}
/**
* Test requests using a serialized field item property.
*
* @see https://security.drupal.org/node/161923
*/
public function testLinkOptionsSerialization() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$document = $this->getPostDocument();
$document['data']['attributes']['link']['options'] = "O:44:\"Symfony\\Component\\Process\\Pipes\\WindowsPipes\":8:{s:51:\"\\Symfony\\Component\\Process\\Pipes\\WindowsPipes\0files\";a:1:{i:0;s:3:\"foo\";}s:57:\"\0Symfony\\Component\\Process\\Pipes\\WindowsPipes\0fileHandles\";a:0:{}s:55:\"\0Symfony\\Component\\Process\\Pipes\\WindowsPipes\0readBytes\";a:2:{i:1;i:0;i:2;i:0;}s:59:\"\0Symfony\\Component\\Process\\Pipes\\WindowsPipes\0disableOutput\";b:0;s:5:\"pipes\";a:0:{}s:58:\"\0Symfony\\Component\\Process\\Pipes\\AbstractPipes\0inputBuffer\";s:0:\"\";s:52:\"\0Symfony\\Component\\Process\\Pipes\\AbstractPipes\0input\";N;s:54:\"\0Symfony\\Component\\Process\\Pipes\\AbstractPipes\0blocked\";b:1;}";
$url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options[RequestOptions::BODY] = Json::encode($document);
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// Ensure 403 when unauthorized.
$response = $this->request('POST', $url, $request_options);
$reason = $this->getExpectedUnauthorizedAccessMessage('POST');
$this->assertResourceErrorResponse(403, (string) $reason, $url, $response);
$this->setUpAuthorization('POST');
// Ensure that an exception is thrown.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(500, (string) 'The generic FieldItemNormalizer cannot denormalize string values for "options" properties of the "link" field (field item class: Drupal\link\Plugin\Field\FieldType\LinkItem).', $url, $response);
// Create a menu link content entity without the serialized property.
unset($document['data']['attributes']['link']['options']);
$request_options[RequestOptions::BODY] = Json::encode($document);
$response = $this->request('POST', $url, $request_options);
$document = Json::decode((string) $response->getBody());
$internal_id = $document['data']['attributes']['drupal_internal__id'];
// Load the created menu item and add link options to it.
$menu_link = MenuLinkContent::load($internal_id);
$menu_link->get('link')->first()->set('options', ['fragment' => 'test']);
$menu_link->save();
// Fetch the link.
unset($request_options[RequestOptions::BODY]);
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $document['data']['id']]);
$response = $this->request('GET', $url, $request_options);
$response_body = (string) $response->getBody();
// Ensure that the entity can be updated using a response document.
$request_options[RequestOptions::BODY] = $response_body;
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, Json::decode($response_body), $response);
}
}
@@ -0,0 +1,111 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\system\Entity\Menu;
/**
* JSON:API integration test for the "Menu" config entity type.
*
* @group jsonapi
*/
class MenuTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'menu';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'menu--menu';
/**
* {@inheritdoc}
*/
protected static $anonymousUsersCanViewLabels = TRUE;
/**
* {@inheritdoc}
*
* @var \Drupal\system\MenuInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer menu']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$menu = Menu::create([
'id' => 'menu',
'label' => 'Menu',
'description' => 'Menu',
]);
$menu->save();
return $menu;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/menu/menu/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'menu--menu',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'description' => 'Menu',
'label' => 'Menu',
'langcode' => 'en',
'locked' => FALSE,
'status' => TRUE,
'drupal_internal__id' => 'menu',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,200 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Utility\NestedArray;
use Drupal\contact\Entity\ContactForm;
use Drupal\contact\Entity\Message;
use Drupal\Core\Url;
use GuzzleHttp\RequestOptions;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
/**
* JSON:API integration test for the "Message" content entity type.
*
* @group jsonapi
*/
class MessageTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['contact'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'contact_message';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'contact_message--camelids';
/**
* {@inheritdoc}
*
* @var \Drupal\contact\MessageInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $labelFieldName = 'subject';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['access site-wide contact form']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
if (!ContactForm::load('camelids')) {
// Create a "Camelids" contact form.
ContactForm::create([
'id' => 'camelids',
'label' => 'Llama',
'message' => 'Let us know what you think about llamas',
'reply' => 'Llamas are indeed awesome!',
'recipients' => [
'llama@example.com',
'contact@example.com',
],
])->save();
}
$message = Message::create([
'contact_form' => 'camelids',
'subject' => 'Llama Gabilondo',
'message' => 'Llamas are awesome!',
]);
$message->save();
return $message;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
throw new \Exception('Not yet supported.');
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'contact_message--camelids',
'attributes' => [
'subject' => 'Dramallama',
'message' => 'http://www.urbandictionary.com/define.php?term=drama%20llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
if ($method === 'POST') {
return "The 'access site-wide contact form' permission is required.";
}
return parent::getExpectedUnauthorizedAccessMessage($method);
}
/**
* {@inheritdoc}
*/
public function testGetIndividual() {
// Contact Message entities are not stored, so they cannot be retrieved.
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "jsonapi.contact_message--camelids.individual" does not exist.');
Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
}
/**
* {@inheritdoc}
*/
public function testPatchIndividual() {
// Contact Message entities are not stored, so they cannot be modified.
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "jsonapi.contact_message--camelids.individual" does not exist.');
Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
}
/**
* {@inheritdoc}
*/
public function testDeleteIndividual() {
// Contact Message entities are not stored, so they cannot be deleted.
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "jsonapi.contact_message--camelids.individual" does not exist.');
Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
}
/**
* {@inheritdoc}
*/
public function testRelated() {
// Contact Message entities are not stored, so they cannot be retrieved.
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "jsonapi.contact_message--camelids.related" does not exist.');
Url::fromRoute('jsonapi.contact_message--camelids.related')->toString(TRUE);
}
/**
* {@inheritdoc}
*/
public function testRelationships() {
// Contact Message entities are not stored, so they cannot be retrieved.
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "jsonapi.contact_message--camelids.relationship.get" does not exist.');
Url::fromRoute('jsonapi.contact_message--camelids.relationship.get')->toString(TRUE);
}
/**
* {@inheritdoc}
*/
public function testCollection() {
$collection_url = Url::fromRoute('jsonapi.contact_message--camelids.collection.post')->setAbsolute(TRUE);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// 405 because Message entities are not stored, so they cannot be retrieved,
// yet the same URL can be used to POST them.
$response = $this->request('GET', $collection_url, $request_options);
$this->assertSame(405, $response->getStatusCode());
$this->assertSame(['POST'], $response->getHeader('Allow'));
}
/**
* {@inheritdoc}
*/
public function testRevisions() {
// Contact Message entities are not stored, so they cannot be retrieved.
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "jsonapi.contact_message--camelids.individual" does not exist.');
Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
}
}
@@ -0,0 +1,514 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "Node" content entity type.
*
* @group jsonapi
*/
class NodeTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'path'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'node';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'node--camelids';
/**
* {@inheritdoc}
*/
protected static $resourceTypeIsVersionable = TRUE;
/**
* {@inheritdoc}
*/
protected static $newRevisionsShouldBeAutomatic = TRUE;
/**
* {@inheritdoc}
*
* @var \Drupal\node\NodeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'revision_timestamp' => NULL,
'created' => "The 'administer nodes' permission is required.",
'changed' => NULL,
'promote' => "The 'administer nodes' permission is required.",
'sticky' => "The 'administer nodes' permission is required.",
'path' => "The following permissions are required: 'create url aliases' OR 'administer url aliases'.",
'revision_uid' => NULL,
];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access content']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
break;
case 'PATCH':
// Do not grant the 'create url aliases' permission to test the case
// when the path field is protected/not accessible, see
// \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase
// for a positive test.
$this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']);
break;
case 'DELETE':
$this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function setUpRevisionAuthorization($method) {
parent::setUpRevisionAuthorization($method);
$this->grantPermissionsToTestedRole(['view all revisions']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
if (!NodeType::load('camelids')) {
// Create a "Camelids" node type.
NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
])->save();
}
// Create a "Llama" node.
$node = Node::create(['type' => 'camelids']);
$node->setTitle('Llama')
->setOwnerId($this->account->id())
->setPublished()
->setCreatedTime(123456789)
->setChangedTime(123456789)
->setRevisionCreationTime(123456789)
->set('path', '/llama')
->save();
return $node;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$author = User::load($this->entity->getOwnerId());
$base_url = Url::fromUri('base:/jsonapi/node/camelids/' . $this->entity->uuid())->setAbsolute();
$self_url = clone $base_url;
$version_identifier = 'id:' . $this->entity->getRevisionId();
$self_url = $self_url->setOption('query', ['resourceVersion' => $version_identifier]);
$version_query_string = '?resourceVersion=' . urlencode($version_identifier);
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $base_url->toString()],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'node--camelids',
'links' => [
'self' => ['href' => $self_url->toString()],
],
'attributes' => [
'created' => '1973-11-29T21:33:09+00:00',
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'default_langcode' => TRUE,
'langcode' => 'en',
'path' => [
'alias' => '/llama',
'pid' => 1,
'langcode' => 'en',
],
'promote' => TRUE,
'revision_log' => NULL,
'revision_timestamp' => '1973-11-29T21:33:09+00:00',
// @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
'revision_translation_affected' => TRUE,
'status' => TRUE,
'sticky' => FALSE,
'title' => 'Llama',
'drupal_internal__nid' => 1,
'drupal_internal__vid' => 1,
],
'relationships' => [
'node_type' => [
'data' => [
'id' => NodeType::load('camelids')->uuid(),
'type' => 'node_type--node_type',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/node_type' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/node_type' . $version_query_string,
],
],
],
'uid' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/uid' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/uid' . $version_query_string,
],
],
],
'revision_uid' => [
'data' => [
'id' => $author->uuid(),
'type' => 'user--user',
],
'links' => [
'related' => [
'href' => $base_url->toString() . '/revision_uid' . $version_query_string,
],
'self' => [
'href' => $base_url->toString() . '/relationships/revision_uid' . $version_query_string,
],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'node--camelids',
'attributes' => [
'title' => 'Dramallama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
case 'POST':
case 'PATCH':
case 'DELETE':
return "The 'access content' permission is required.";
}
}
/**
* Tests PATCHing a node's path with and without 'create url aliases'.
*
* For a positive test, see the similar test coverage for Term.
*
* @see \Drupal\Tests\jsonapi\Functional\TermTest::testPatchPath()
* @see \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase::testPatchPath()
*/
public function testPatchPath() {
$this->setUpAuthorization('GET');
$this->setUpAuthorization('PATCH');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
// $url = $this->entity->toUrl('jsonapi');
// GET node's current normalization.
$response = $this->request('GET', $url, $this->getAuthenticationRequestOptions());
$normalization = Json::decode((string) $response->getBody());
// Change node's path alias.
$normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world';
// Create node PATCH request.
$request_options = $this->getAuthenticationRequestOptions();
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// PATCH request: 403 when creating URL aliases unauthorized.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (path). The following permissions are required: 'create url aliases' OR 'administer url aliases'.", $url, $response, '/data/attributes/path');
// Grant permission to create URL aliases.
$this->grantPermissionsToTestedRole(['create url aliases']);
// Repeat PATCH request: 200.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response);
$updated_normalization = Json::decode((string) $response->getBody());
$this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']);
}
/**
* {@inheritdoc}
*/
public function testGetIndividual() {
parent::testGetIndividual();
$this->assertCacheableNormalizations();
// Unpublish node.
$this->entity->setUnpublished()->save();
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
// $url = $this->entity->toUrl('jsonapi');
$request_options = $this->getAuthenticationRequestOptions();
// 403 when accessing own unpublished node.
$response = $this->request('GET', $url, $request_options);
// @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
$expected_document = [
'jsonapi' => static::$jsonApiMember,
'errors' => [
[
'title' => 'Forbidden',
'status' => '403',
'detail' => 'The current user is not allowed to GET the selected resource.',
'links' => [
'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
'via' => ['href' => $url->setAbsolute()->toString()],
],
'source' => [
'pointer' => '/data',
],
],
],
];
$this->assertResourceResponse(
403,
$expected_document,
$response,
['4xx-response', 'http_response', 'node:1'],
['url.query_args:resourceVersion', 'url.site', 'user.permissions'],
FALSE,
'MISS'
);
/* $this->assertResourceErrorResponse(403, 'The current user is not allowed to GET the selected resource.', $response, '/data'); */
// 200 after granting permission.
$this->grantPermissionsToTestedRole(['view own unpublished content']);
$response = $this->request('GET', $url, $request_options);
// The response varies by 'user', causing the 'user.permissions' cache
// context to be optimized away.
$expected_cache_contexts = Cache::mergeContexts($this->getExpectedCacheContexts(), ['user']);
$expected_cache_contexts = array_diff($expected_cache_contexts, ['user.permissions']);
$this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $expected_cache_contexts, FALSE, 'UNCACHEABLE');
}
/**
* Asserts that normalizations are cached in an incremental way.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function assertCacheableNormalizations() {
// Save the entity to invalidate caches.
$this->entity->save();
$uuid = $this->entity->uuid();
$cache = \Drupal::service('render_cache')->get([
'#cache' => [
'keys' => ['node--camelids', $uuid],
'bin' => 'jsonapi_normalizations',
],
]);
// After saving the entity the normalization should not be cached.
$this->assertFalse($cache);
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]);
// $url = $this->entity->toUrl('jsonapi');
$request_options = $this->getAuthenticationRequestOptions();
$request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'title']];
$this->request('GET', $url, $request_options);
// Ensure the normalization cache is being incrementally built. After
// requesting the title, only the title is in the cache.
$this->assertNormalizedFieldsAreCached(['title']);
$request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'field_rest_test']];
$this->request('GET', $url, $request_options);
// After requesting an additional field, then that field is in the cache and
// the old one is still there.
$this->assertNormalizedFieldsAreCached(['title', 'field_rest_test']);
}
/**
* Checks that the provided field names are the only fields in the cache.
*
* The normalization cache should only have these fields, which build up
* across responses.
*
* @param string[] $field_names
* The field names.
*/
protected function assertNormalizedFieldsAreCached($field_names) {
$cache = \Drupal::service('render_cache')->get([
'#cache' => [
'keys' => ['node--camelids', $this->entity->uuid()],
'bin' => 'jsonapi_normalizations',
],
]);
$cached_fields = $cache['#data']['fields'];
$this->assertCount(count($field_names), $cached_fields);
array_walk($field_names, function ($field_name) use ($cached_fields) {
$this->assertInstanceOf(
CacheableNormalization::class,
$cached_fields[$field_name]
);
});
}
/**
* {@inheritdoc}
*/
protected static function getIncludePermissions() {
return [
'uid.node_type' => ['administer users'],
'uid.roles' => ['administer permissions'],
];
}
/**
* Creating relationships to missing resources should be 404 per JSON:API 1.1.
*
* @see https://github.com/json-api/json-api/issues/1033
*/
public function testPostNonExistingAuthor() {
$this->setUpAuthorization('POST');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$this->grantPermissionsToTestedRole(['administer nodes']);
$random_uuid = \Drupal::service('uuid')->generate();
$doc = $this->getPostDocument();
$doc['data']['relationships']['uid']['data'] = [
'type' => 'user--user',
'id' => $random_uuid,
];
// Create node POST request.
$url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
$request_options = $this->getAuthenticationRequestOptions();
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options[RequestOptions::BODY] = Json::encode($doc);
// POST request: 404 when adding relationships to non-existing resources.
$response = $this->request('POST', $url, $request_options);
$expected_document = [
'errors' => [
0 => [
'status' => '404',
'title' => 'Not Found',
'detail' => "The resource identified by `user--user:$random_uuid` (given as a relationship item) could not be found.",
'links' => [
'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(404)],
'via' => ['href' => $url->setAbsolute()->toString()],
],
],
],
'jsonapi' => static::$jsonApiMember,
];
$this->assertResourceResponse(404, $expected_document, $response);
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$label_field_name = 'title';
$this->doTestCollectionFilterAccessForPublishableEntities($label_field_name, 'access content', 'bypass node access');
$collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
$collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$this->revokePermissionsFromTestedRole(['bypass node access']);
// 0 results because the node is unpublished.
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
$this->grantPermissionsToTestedRole(['view own unpublished content']);
// 1 result because the current user is the owner of the unpublished node.
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
$this->entity->setOwnerId(0)->save();
// 0 results because the current user is no longer the owner.
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// Assert bubbling of cacheability from query alter hook.
$this->assertTrue($this->container->get('module_installer')->install(['node_access_test'], TRUE), 'Installed modules.');
node_access_rebuild();
$this->rebuildAll();
$response = $this->request('GET', $collection_filter_url, $request_options);
$this->assertContains('user.node_grants:view', explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
}
}
@@ -0,0 +1,118 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\node\Entity\NodeType;
/**
* JSON:API integration test for the "NodeType" config entity type.
*
* @group jsonapi
*/
class NodeTypeTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'node_type';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'node_type--node_type';
/**
* {@inheritdoc}
*
* @var \Drupal\node\NodeTypeInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer content types', 'access content']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" node type.
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
]);
$camelids->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/node_type/node_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'node_type--node_type',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
'display_submitted' => TRUE,
'help' => NULL,
'langcode' => 'en',
'name' => 'Camelids',
'new_revision' => TRUE,
'preview_mode' => 1,
'status' => TRUE,
'drupal_internal__type' => 'camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'access content' permission is required.";
}
}
@@ -0,0 +1,119 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\path_alias\Entity\PathAlias;
use Drupal\Core\Url;
/**
* JSON:API integration test for the "PathAlias" content entity type.
*
* @group jsonapi
* @group path
*/
class PathAliasTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['user'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'path_alias';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'path_alias--path_alias';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* {@inheritdoc}
*
* @var \Drupal\user\RoleInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer url aliases']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$path_alias = PathAlias::create([
'alias' => '/frontpage1',
'path' => '/<front>',
'langcode' => 'en',
]);
$path_alias->save();
return $path_alias;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/path_alias/path_alias/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => static::$resourceTypeName,
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'alias' => '/frontpage1',
'path' => '/<front>',
'langcode' => 'en',
'status' => TRUE,
'drupal_internal__id' => 1,
'drupal_internal__revision_id' => 1,
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => static::$resourceTypeName,
'attributes' => [
'alias' => '/frontpage1',
'path' => '/<front>',
'langcode' => 'en',
],
],
];
}
}
@@ -0,0 +1,153 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\node\Entity\NodeType;
use Drupal\rdf\Entity\RdfMapping;
/**
* JSON:API integration test for the "RdfMapping" config entity type.
*
* @group jsonapi
*/
class RdfMappingTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'rdf'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'rdf_mapping';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'rdf_mapping--rdf_mapping';
/**
* {@inheritdoc}
*
* @var \Drupal\rdf\RdfMappingInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer site configuration']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" node type.
$camelids = NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
]);
$camelids->save();
// Create the RDF mapping.
$llama = RdfMapping::create([
'targetEntityType' => 'node',
'bundle' => 'camelids',
]);
$llama->setBundleMapping([
'types' => ['sioc:Item', 'foaf:Document'],
])
->setFieldMapping('title', [
'properties' => ['dc:title'],
])
->setFieldMapping('created', [
'properties' => ['dc:date', 'dc:created'],
'datatype' => 'xsd:dateTime',
'datatype_callback' => ['callable' => 'Drupal\rdf\CommonDataConverter::dateIso8601Value'],
])
->save();
return $llama;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/rdf_mapping/rdf_mapping/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'rdf_mapping--rdf_mapping',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'bundle' => 'camelids',
'dependencies' => [
'config' => [
'node.type.camelids',
],
'module' => [
'node',
],
],
'fieldMappings' => [
'title' => [
'properties' => [
'dc:title',
],
],
'created' => [
'properties' => [
'dc:date',
'dc:created',
],
'datatype' => 'xsd:dateTime',
'datatype_callback' => [
'callable' => 'Drupal\rdf\CommonDataConverter::dateIso8601Value',
],
],
],
'langcode' => 'en',
'status' => TRUE,
'targetEntityType' => 'node',
'types' => [
'sioc:Item',
'foaf:Document',
],
'drupal_internal__id' => 'node.camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,666 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Url;
use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
use Drupal\jsonapi\ResourceResponse;
use Psr\Http\Message\ResponseInterface;
/**
* Utility methods for handling resource responses.
*
* @internal
*/
trait ResourceResponseTestTrait {
/**
* Merges individual responses into a collection response.
*
* Here, a collection response refers to a response with multiple resource
* objects. Not necessarily to a response to a collection route. In both
* cases, the document should indistinguishable.
*
* @param \Drupal\jsonapi\ResourceResponse[] $responses
* An array or ResourceResponses to be merged.
* @param string|null $self_link
* The self link for the merged document if one should be set.
* @param bool $is_multiple
* Whether the responses are for a multiple cardinality field. This cannot
* be deduced from the number of responses, because a multiple cardinality
* field may have only one value.
*
* @return \Drupal\jsonapi\ResourceResponse
* The merged ResourceResponse.
*/
protected static function toCollectionResourceResponse(array $responses, $self_link, $is_multiple) {
assert(count($responses) > 0);
$merged_document = [];
$merged_cacheability = new CacheableMetadata();
foreach ($responses as $response) {
$response_document = $response->getResponseData();
// If any of the response documents had top-level errors, we should later
// expect the merged document to have all errors as omitted links under
// the 'meta.omitted' member.
if (!empty($response_document['errors'])) {
static::addOmittedObject($merged_document, static::errorsToOmittedObject($response_document['errors']));
}
if (!empty($response_document['meta']['omitted'])) {
static::addOmittedObject($merged_document, $response_document['meta']['omitted']);
}
elseif (isset($response_document['data'])) {
$response_data = $response_document['data'];
if (!isset($merged_document['data'])) {
$merged_document['data'] = static::isResourceIdentifier($response_data) && $is_multiple
? [$response_data]
: $response_data;
}
else {
$response_resources = static::isResourceIdentifier($response_data)
? [$response_data]
: $response_data;
foreach ($response_resources as $response_resource) {
$merged_document['data'][] = $response_resource;
}
}
}
$merged_cacheability->addCacheableDependency($response->getCacheableMetadata());
}
$merged_document['jsonapi'] = [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
];
// Until we can reasonably know what caused an error, we shouldn't include
// 'self' links in error documents. For example, a 404 shouldn't have a
// 'self' link because HATEOAS links shouldn't point to resources which do
// not exist.
if (isset($merged_document['errors'])) {
unset($merged_document['links']);
}
else {
if (!isset($merged_document['data'])) {
$merged_document['data'] = $is_multiple ? [] : NULL;
}
$merged_document['links'] = [
'self' => [
'href' => $self_link,
],
];
}
// All collections should be 200, without regard for the status of the
// individual resources in those collections, which means any '4xx-response'
// cache tags on the individual responses should also be omitted.
$merged_cacheability->setCacheTags(array_diff($merged_cacheability->getCacheTags(), ['4xx-response']));
return (new ResourceResponse($merged_document, 200))->addCacheableDependency($merged_cacheability);
}
/**
* Gets an array of expected ResourceResponses for the given include paths.
*
* @param array $include_paths
* The list of relationship include paths for which to get expected data.
* @param array $request_options
* Request options to apply.
*
* @return \Drupal\jsonapi\ResourceResponse
* The expected ResourceResponse.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getExpectedIncludedResourceResponse(array $include_paths, array $request_options) {
$resource_type = $this->resourceType;
$resource_data = array_reduce($include_paths, function ($data, $path) use ($request_options, $resource_type) {
$field_names = explode('.', $path);
/* @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $this->entity;
$collected_responses = [];
foreach ($field_names as $public_field_name) {
$resource_type = $this->container->get('jsonapi.resource_type.repository')->get($entity->getEntityTypeId(), $entity->bundle());
$field_name = $resource_type->getInternalName($public_field_name);
$field_access = static::entityFieldAccess($entity, $field_name, 'view', $this->account);
if (!$field_access->isAllowed()) {
if (!$entity->access('view') && $entity->access('view label') && $field_access instanceof AccessResultReasonInterface && empty($field_access->getReason())) {
$field_access->setReason("The user only has authorization for the 'view label' operation.");
}
$via_link = Url::fromRoute(
sprintf('jsonapi.%s.%s.related', $entity->getEntityTypeId() . '--' . $entity->bundle(), $public_field_name),
['entity' => $entity->uuid()]
);
$collected_responses[] = static::getAccessDeniedResponse($entity, $field_access, $via_link, $field_name, 'The current user is not allowed to view this relationship.', $field_name);
break;
}
if ($target_entity = $entity->{$field_name}->entity) {
$target_access = static::entityAccess($target_entity, 'view', $this->account);
if (!$target_access->isAllowed()) {
$target_access = static::entityAccess($target_entity, 'view label', $this->account)->addCacheableDependency($target_access);
}
if (!$target_access->isAllowed()) {
$resource_identifier = static::toResourceIdentifier($target_entity);
if (!static::collectionHasResourceIdentifier($resource_identifier, $data['already_checked'])) {
$data['already_checked'][] = $resource_identifier;
$via_link = Url::fromRoute(
sprintf('jsonapi.%s.individual', $resource_identifier['type']),
['entity' => $resource_identifier['id']]
);
$collected_responses[] = static::getAccessDeniedResponse($entity, $target_access, $via_link, NULL, NULL, '/data');
}
break;
}
}
$psr_responses = $this->getResponses([static::getRelatedLink(static::toResourceIdentifier($entity), $public_field_name)], $request_options);
$collected_responses[] = static::toCollectionResourceResponse(static::toResourceResponses($psr_responses), NULL, TRUE);
$entity = $entity->{$field_name}->entity;
}
if (!empty($collected_responses)) {
$data['responses'][$path] = static::toCollectionResourceResponse($collected_responses, NULL, TRUE);
}
return $data;
}, ['responses' => [], 'already_checked' => []]);
$individual_document = $this->getExpectedDocument();
$expected_base_url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
$include_url = clone $expected_base_url;
$query = ['include' => implode(',', $include_paths)];
$include_url->setOption('query', $query);
$individual_document['links']['self']['href'] = $include_url->toString();
// The test entity reference field should always be present.
if (!isset($individual_document['data']['relationships']['field_jsonapi_test_entity_ref'])) {
if (static::$resourceTypeIsVersionable) {
assert($this->entity instanceof RevisionableInterface);
$version_identifier = 'id:' . $this->entity->getRevisionId();
$version_query_string = '?resourceVersion=' . urlencode($version_identifier);
}
else {
$version_query_string = '';
}
$individual_document['data']['relationships']['field_jsonapi_test_entity_ref'] = [
'data' => [],
'links' => [
'related' => [
'href' => $expected_base_url->toString() . '/field_jsonapi_test_entity_ref' . $version_query_string,
],
'self' => [
'href' => $expected_base_url->toString() . '/relationships/field_jsonapi_test_entity_ref' . $version_query_string,
],
],
];
}
$basic_cacheability = (new CacheableMetadata())
->addCacheTags($this->getExpectedCacheTags())
->addCacheContexts($this->getExpectedCacheContexts());
return static::decorateExpectedResponseForIncludedFields(ResourceResponse::create($individual_document), $resource_data['responses'])
->addCacheableDependency($basic_cacheability);
}
/**
* Maps an array of PSR responses to JSON:API ResourceResponses.
*
* @param \Psr\Http\Message\ResponseInterface[] $responses
* The PSR responses to be mapped.
*
* @return \Drupal\jsonapi\ResourceResponse[]
* The ResourceResponses.
*/
protected static function toResourceResponses(array $responses) {
return array_map([self::class, 'toResourceResponse'], $responses);
}
/**
* Maps a response object to a JSON:API ResourceResponse.
*
* This helper can be used to ease comparing, recording and merging
* cacheable responses and to have easier access to the JSON:API document as
* an array instead of a string.
*
* @param \Psr\Http\Message\ResponseInterface $response
* A PSR response to be mapped.
*
* @return \Drupal\jsonapi\ResourceResponse
* The ResourceResponse.
*/
protected static function toResourceResponse(ResponseInterface $response) {
$cacheability = new CacheableMetadata();
if ($cache_tags = $response->getHeader('X-Drupal-Cache-Tags')) {
$cacheability->addCacheTags(explode(' ', $cache_tags[0]));
}
if (!empty($response->getHeaderLine('X-Drupal-Cache-Contexts'))) {
$cacheability->addCacheContexts(explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
}
if ($dynamic_cache = $response->getHeader('X-Drupal-Dynamic-Cache')) {
$cacheability->setCacheMaxAge(($dynamic_cache[0] === 'UNCACHEABLE' && $response->getStatusCode() < 400) ? 0 : Cache::PERMANENT);
}
$related_document = Json::decode($response->getBody());
$resource_response = new ResourceResponse($related_document, $response->getStatusCode());
return $resource_response->addCacheableDependency($cacheability);
}
/**
* Maps an entity to a resource identifier.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to map to a resource identifier.
*
* @return array
* A resource identifier for the given entity.
*/
protected static function toResourceIdentifier(EntityInterface $entity) {
return [
'type' => $entity->getEntityTypeId() . '--' . $entity->bundle(),
'id' => $entity->uuid(),
];
}
/**
* Checks if a given array is a resource identifier.
*
* @param array $data
* An array to check.
*
* @return bool
* TRUE if the array has a type and ID, FALSE otherwise.
*/
protected static function isResourceIdentifier(array $data) {
return array_key_exists('type', $data) && array_key_exists('id', $data);
}
/**
* Sorts a collection of resources or resource identifiers.
*
* This is useful for asserting collections or resources where order cannot
* be known in advance.
*
* @param array $resources
* The resource or resource identifier.
*/
protected static function sortResourceCollection(array &$resources) {
usort($resources, function ($a, $b) {
return strcmp("{$a['type']}:{$a['id']}", "{$b['type']}:{$b['id']}");
});
}
/**
* Determines if a given resource exists in a list of resources.
*
* @param array $needle
* The resource or resource identifier.
* @param array $haystack
* The list of resources or resource identifiers to search.
*
* @return bool
* TRUE if the needle exists is present in the haystack, FALSE otherwise.
*/
protected static function collectionHasResourceIdentifier(array $needle, array $haystack) {
foreach ($haystack as $resource) {
if ($resource['type'] == $needle['type'] && $resource['id'] == $needle['id']) {
return TRUE;
}
}
return FALSE;
}
/**
* Turns a list of relationship field names into an array of link paths.
*
* @param array $relationship_field_names
* The relationships field names for which to build link paths.
* @param string $type
* The type of link to get. Either 'relationship' or 'related'.
*
* @return array
* An array of link paths, keyed by relationship field name.
*/
protected static function getLinkPaths(array $relationship_field_names, $type) {
assert($type === 'relationship' || $type === 'related');
return array_reduce($relationship_field_names, function ($link_paths, $relationship_field_name) use ($type) {
$tail = $type === 'relationship' ? 'self' : $type;
$link_paths[$relationship_field_name] = "data.relationships.$relationship_field_name.links.$tail.href";
return $link_paths;
}, []);
}
/**
* Extracts links from a document using a list of relationship field names.
*
* @param array $link_paths
* A list of paths to link values keyed by a name.
* @param array $document
* A JSON:API document.
*
* @return array
* The extracted links, keyed by the original associated key name.
*/
protected static function extractLinks(array $link_paths, array $document) {
return array_map(function ($link_path) use ($document) {
$link = array_reduce(
explode('.', $link_path),
'array_column',
[$document]
);
return ($link) ? reset($link) : NULL;
}, $link_paths);
}
/**
* Creates individual resource links for a list of resource identifiers.
*
* @param array $resource_identifiers
* A list of resource identifiers for which to create links.
*
* @return string[]
* The resource links.
*/
protected static function getResourceLinks(array $resource_identifiers) {
return array_map([static::class, 'getResourceLink'], $resource_identifiers);
}
/**
* Creates an individual resource link for a given resource identifier.
*
* @param array $resource_identifier
* A resource identifier for which to create a link.
*
* @return string
* The resource link.
*/
protected static function getResourceLink(array $resource_identifier) {
assert(static::isResourceIdentifier($resource_identifier));
$resource_type = $resource_identifier['type'];
$resource_id = $resource_identifier['id'];
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', $resource_type), ['entity' => $resource_id]);
return $url->setAbsolute()->toString();
}
/**
* Creates a relationship link for a given resource identifier and field.
*
* @param array $resource_identifier
* A resource identifier for which to create a link.
* @param string $relationship_field_name
* The relationship field for which to create a link.
*
* @return string
* The relationship link.
*/
protected static function getRelationshipLink(array $resource_identifier, $relationship_field_name) {
return static::getResourceLink($resource_identifier) . "/relationships/$relationship_field_name";
}
/**
* Creates a related resource link for a given resource identifier and field.
*
* @param array $resource_identifier
* A resource identifier for which to create a link.
* @param string $relationship_field_name
* The relationship field for which to create a link.
*
* @return string
* The related resource link.
*/
protected static function getRelatedLink(array $resource_identifier, $relationship_field_name) {
return static::getResourceLink($resource_identifier) . "/$relationship_field_name";
}
/**
* Gets an array of related responses for the given field names.
*
* @param array $relationship_field_names
* The list of relationship field names for which to get responses.
* @param array $request_options
* Request options to apply.
* @param \Drupal\Core\Entity\EntityInterface|null $entity
* (optional) The entity for which to get expected related responses.
*
* @return array
* The related responses, keyed by relationship field names.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
$entity = $entity ?: $this->entity;
$links = array_map(function ($relationship_field_name) use ($entity) {
return static::getRelatedLink(static::toResourceIdentifier($entity), $relationship_field_name);
}, array_combine($relationship_field_names, $relationship_field_names));
return $this->getResponses($links, $request_options);
}
/**
* Gets an array of relationship responses for the given field names.
*
* @param array $relationship_field_names
* The list of relationship field names for which to get responses.
* @param array $request_options
* Request options to apply.
*
* @return array
* The relationship responses, keyed by relationship field names.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getRelationshipResponses(array $relationship_field_names, array $request_options) {
$links = array_map(function ($relationship_field_name) {
return static::getRelationshipLink(static::toResourceIdentifier($this->entity), $relationship_field_name);
}, array_combine($relationship_field_names, $relationship_field_names));
return $this->getResponses($links, $request_options);
}
/**
* Gets responses from an array of links.
*
* @param array $links
* A keyed array of links.
* @param array $request_options
* Request options to apply.
*
* @return array
* The fetched array of responses, keys are preserved.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getResponses(array $links, array $request_options) {
return array_reduce(array_keys($links), function ($related_responses, $key) use ($links, $request_options) {
$related_responses[$key] = $this->request('GET', Url::fromUri($links[$key]), $request_options);
return $related_responses;
}, []);
}
/**
* Gets a generic forbidden response.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity for which to generate the forbidden response.
* @param \Drupal\Core\Access\AccessResultInterface $access
* The denied AccessResult. This can carry a reason and cacheability data.
* @param \Drupal\Core\Url $via_link
* The source URL for the errors of the response.
* @param string|null $relationship_field_name
* (optional) The field name to which the forbidden result applies. Useful
* for testing related/relationship routes and includes.
* @param string|null $detail
* (optional) Details for the JSON:API error object.
* @param string|bool|null $pointer
* (optional) Document pointer for the JSON:API error object. FALSE to omit
* the pointer.
*
* @return \Drupal\jsonapi\ResourceResponse
* The forbidden ResourceResponse.
*/
protected static function getAccessDeniedResponse(EntityInterface $entity, AccessResultInterface $access, Url $via_link, $relationship_field_name = NULL, $detail = NULL, $pointer = NULL) {
$detail = ($detail) ? $detail : 'The current user is not allowed to GET the selected resource.';
if ($access instanceof AccessResultReasonInterface && ($reason = $access->getReason())) {
$detail .= ' ' . $reason;
}
$error = [
'status' => '403',
'title' => 'Forbidden',
'detail' => $detail,
'links' => [
'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
],
];
if ($pointer || $pointer !== FALSE && $relationship_field_name) {
$error['source']['pointer'] = ($pointer) ? $pointer : $relationship_field_name;
}
if ($via_link) {
$error['links']['via']['href'] = $via_link->setAbsolute()->toString();
}
return (new ResourceResponse([
'jsonapi' => static::$jsonApiMember,
'errors' => [$error],
], 403))
->addCacheableDependency((new CacheableMetadata())->addCacheTags(['4xx-response', 'http_response'])->addCacheContexts(['url.site']))
->addCacheableDependency($access);
}
/**
* Gets a generic empty collection response.
*
* @param int $cardinality
* The cardinality of the resource collection. 1 for a to-one related
* resource collection; -1 for an unlimited cardinality.
* @param string $self_link
* The self link for collection ResourceResponse.
*
* @return \Drupal\jsonapi\ResourceResponse
* The empty collection ResourceResponse.
*/
protected function getEmptyCollectionResponse($cardinality, $self_link) {
// If the entity type is revisionable, add a resource version cache context.
$cache_contexts = Cache::mergeContexts([
// Cache contexts for JSON:API URL query parameters.
'url.query_args:fields',
'url.query_args:include',
// Drupal defaults.
'url.site',
], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
$cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']);
return (new ResourceResponse([
// Empty to-one relationships should be NULL and empty to-many
// relationships should be an empty array.
'data' => $cardinality === 1 ? NULL : [],
'jsonapi' => static::$jsonApiMember,
'links' => ['self' => ['href' => $self_link]],
]))->addCacheableDependency($cacheability);
}
/**
* Add the omitted object to the document or merges it if one already exists.
*
* @param array $document
* The JSON:API response document.
* @param array $omitted
* The omitted object.
*/
protected static function addOmittedObject(array &$document, array $omitted) {
if (isset($document['meta']['omitted'])) {
$document['meta']['omitted'] = static::mergeOmittedObjects($document['meta']['omitted'], $omitted);
}
else {
$document['meta']['omitted'] = $omitted;
}
}
/**
* Maps error objects into an omitted object.
*
* @param array $errors
* An array of error objects.
*
* @return array
* A new omitted object.
*/
protected static function errorsToOmittedObject(array $errors) {
$omitted = [
'detail' => 'Some resources have been omitted because of insufficient authorization.',
'links' => [
'help' => [
'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control',
],
],
];
foreach ($errors as $error) {
$omitted['links']['item--' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = [
'href' => $error['links']['via']['href'],
'meta' => [
'detail' => $error['detail'],
'rel' => 'item',
],
];
}
return $omitted;
}
/**
* Merges the links of two omitted objects and returns a new omitted object.
*
* @param array $a
* The first omitted object.
* @param array $b
* The second omitted object.
*
* @return mixed
* A new, merged omitted object.
*/
protected static function mergeOmittedObjects(array $a, array $b) {
$merged['detail'] = 'Some resources have been omitted because of insufficient authorization.';
$merged['links']['help']['href'] = 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control';
$a_links = array_diff_key($a['links'], array_flip(['help']));
$b_links = array_diff_key($b['links'], array_flip(['help']));
foreach (array_merge(array_values($a_links), array_values($b_links)) as $link) {
$merged['links'][$link['href'] . $link['meta']['detail']] = $link;
}
static::resetOmittedLinkKeys($merged);
return $merged;
}
/**
* Sorts an omitted link object array by href.
*
* @param array $omitted
* An array of JSON:API omitted link objects.
*/
protected static function sortOmittedLinks(array &$omitted) {
$help = $omitted['links']['help'];
$links = array_diff_key($omitted['links'], array_flip(['help']));
uasort($links, function ($a, $b) {
return strcmp($a['href'], $b['href']);
});
$omitted['links'] = ['help' => $help] + $links;
}
/**
* Resets omitted link keys.
*
* Omitted link keys are a link relation type + a random string. This string
* is meaningless and only serves to differentiate link objects. Given that
* these are random, we can't assert their value.
*
* @param array $omitted
* An array of JSON:API omitted link objects.
*/
protected static function resetOmittedLinkKeys(array &$omitted) {
$help = $omitted['links']['help'];
$reindexed = [];
$links = array_diff_key($omitted['links'], array_flip(['help']));
foreach (array_values($links) as $index => $link) {
$reindexed['item--' . $index] = $link;
}
$omitted['links'] = ['help' => $help] + $reindexed;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\responsive_image\Entity\ResponsiveImageStyle;
/**
* JSON:API integration test for the "ResponsiveImageStyle" config entity type.
*
* @group jsonapi
*/
class ResponsiveImageStyleTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['responsive_image'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'responsive_image_style';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'responsive_image_style--responsive_image_style';
/**
* {@inheritdoc}
*
* @var \Drupal\responsive_image\ResponsiveImageStyleInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer responsive images']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Camelids" responsive image style.
$camelids = ResponsiveImageStyle::create([
'id' => 'camelids',
'label' => 'Camelids',
]);
$camelids->setBreakpointGroup('test_group');
$camelids->setFallbackImageStyle('fallback');
$camelids->addImageStyleMapping('test_breakpoint', '1x', [
'image_mapping_type' => 'image_style',
'image_mapping' => 'small',
]);
$camelids->addImageStyleMapping('test_breakpoint', '2x', [
'image_mapping_type' => 'sizes',
'image_mapping' => [
'sizes' => '(min-width:700px) 700px, 100vw',
'sizes_image_styles' => [
'medium' => 'medium',
'large' => 'large',
],
],
]);
$camelids->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/responsive_image_style/responsive_image_style/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'responsive_image_style--responsive_image_style',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'breakpoint_group' => 'test_group',
'dependencies' => [
'config' => [
'image.style.large',
'image.style.medium',
],
],
'fallback_image_style' => 'fallback',
'image_style_mappings' => [
0 => [
'breakpoint_id' => 'test_breakpoint',
'image_mapping' => 'small',
'image_mapping_type' => 'image_style',
'multiplier' => '1x',
],
1 => [
'breakpoint_id' => 'test_breakpoint',
'image_mapping' => [
'sizes' => '(min-width:700px) 700px, 100vw',
'sizes_image_styles' => [
'large' => 'large',
'medium' => 'medium',
],
],
'image_mapping_type' => 'sizes',
'multiplier' => '2x',
],
],
'label' => 'Camelids',
'langcode' => 'en',
'status' => TRUE,
'drupal_internal__id' => 'camelids',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,54 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Tests\ViewTestData;
/**
* Ensures that the 'api_json' format is not supported by the REST module.
*
* @group jsonapi
*
* @internal
*/
class RestExportJsonApiUnsupported extends ViewTestBase {
/**
* {@inheritdoc}
*/
public static $testViews = ['test_serializer_display_entity'];
/**
* {@inheritdoc}
*/
public static $modules = ['jsonapi', 'rest_test_views', 'views_ui'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE) {
parent::setUp($import_test_views);
ViewTestData::createTestViews(get_class($this), ['rest_test_views']);
$this->drupalLogin($this->drupalCreateUser(['administer views']));
}
/**
* Tests that 'api_json' is not a RestExport format option.
*/
public function testFormatOptions() {
$this->assertSame(['json' => 'serialization', 'xml' => 'serialization'], $this->container->getParameter('serializer.format_providers'));
$this->drupalGet('admin/structure/views/nojs/display/test_serializer_display_entity/rest_export_1/style_options');
$this->assertSession()->fieldExists('style_options[formats][json]');
$this->assertSession()->fieldExists('style_options[formats][xml]');
$this->assertSession()->fieldNotExists('style_options[formats][api_json]');
}
}
@@ -0,0 +1,132 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\ResourceTestBase;
/**
* Ensures that the 'api_json' format is not supported by the REST module.
*
* @group jsonapi
*
* @internal
*/
class RestJsonApiUnsupported extends ResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['jsonapi', 'node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'api_json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/vnd.api+json';
/**
* {@inheritdoc}
*/
protected static $resourceConfigId = 'entity.node';
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access content']);
break;
default:
throw new \UnexpectedValueException();
}
}
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
// Set up a HTTP client that accepts relative URLs.
$this->httpClient = $this->container->get('http_client_factory')
->fromOptions(['base_uri' => $this->baseUrl]);
// Create a "Camelids" node type.
NodeType::create([
'name' => 'Camelids',
'type' => 'camelids',
])->save();
// Create a "Llama" node.
$node = Node::create(['type' => 'camelids']);
$node->setTitle('Llama')
->setOwnerId(0)
->setPublished()
->save();
}
/**
* Deploying a REST resource using api_json format results in 400 responses.
*
* @see \Drupal\jsonapi\EventSubscriber\JsonApiRequestValidator::validateQueryParams()
*/
public function testApiJsonNotSupportedInRest() {
$this->assertSame(['json', 'xml'], $this->container->getParameter('serializer.formats'));
$this->provisionResource(['api_json'], []);
$this->setUpAuthorization('GET');
$url = Node::load(1)->toUrl()
->setOption('query', ['_format' => 'api_json']);
$request_options = [];
$response = $this->request('GET', $url, $request_options);
$this->assertResourceErrorResponse(
400,
FALSE,
$response,
['4xx-response', 'config:user.role.anonymous', 'http_response', 'node:1'],
['url.query_args:_format', 'url.site', 'user.permissions'],
'MISS',
'MISS'
);
}
/**
* {@inheritdoc}
*/
protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {}
/**
* {@inheritdoc}
*/
protected function getExpectedBcUnauthorizedAccessMessage($method) {}
}
@@ -0,0 +1,131 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\rest\Entity\RestResourceConfig;
/**
* JSON:API integration test for the "RestResourceConfig" config entity type.
*
* @group jsonapi
*/
class RestResourceConfigTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['rest', 'dblog'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'rest_resource_config';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'rest_resource_config--rest_resource_config';
/**
* {@inheritdoc}
*
* @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 getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/rest_resource_config/rest_resource_config/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'rest_resource_config--rest_resource_config',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [
'module' => [
'dblog',
'serialization',
'user',
],
],
'plugin_id' => 'dblog',
'granularity' => 'method',
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
'drupal_internal__id' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,107 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\user\Entity\Role;
/**
* JSON:API integration test for the "Role" config entity type.
*
* @group jsonapi
*/
class RoleTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['user'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'user_role';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'user_role--user_role';
/**
* {@inheritdoc}
*
* @var \Drupal\user\RoleInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer permissions']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$role = Role::create([
'id' => 'llama',
'name' => $this->randomString(),
]);
$role->save();
return $role;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/user_role/user_role/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'user_role--user_role',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'weight' => 2,
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [],
'label' => NULL,
'is_admin' => NULL,
'permissions' => [],
'drupal_internal__id' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,146 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\search\Entity\SearchPage;
/**
* JSON:API integration test for the "SearchPage" config entity type.
*
* @group jsonapi
*/
class SearchPageTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'search_page';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'search_page--search_page';
/**
* {@inheritdoc}
*
* @var \Drupal\search\SearchPageInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access content']);
break;
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer search']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$search_page = SearchPage::create([
'id' => 'hinode_search',
'plugin' => 'node_search',
'label' => 'Search of magnetic activity of the Sun',
'path' => 'sun',
]);
$search_page->save();
return $search_page;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/search_page/search_page/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'search_page--search_page',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'configuration' => [
'rankings' => [],
],
'dependencies' => [
'module' => [
'node',
],
],
'label' => 'Search of magnetic activity of the Sun',
'langcode' => 'en',
'path' => 'sun',
'plugin' => 'node_search',
'status' => TRUE,
'weight' => 0,
'drupal_internal__id' => 'hinode_search',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access content' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// @see \Drupal\search\SearchPageAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
->addCacheTags(['config:search.page.hinode_search']);
}
}
@@ -0,0 +1,128 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\shortcut\Entity\ShortcutSet;
/**
* JSON:API integration test for the "ShortcutSet" config entity type.
*
* @group jsonapi
*/
class ShortcutSetTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['shortcut'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'shortcut_set';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'shortcut_set--shortcut_set';
/**
* {@inheritdoc}
*
* @var \Drupal\shortcut\ShortcutSetInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access shortcuts']);
break;
case 'POST':
case 'PATCH':
$this->grantPermissionsToTestedRole(['access shortcuts', 'customize shortcut links']);
break;
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer shortcuts']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access shortcuts' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$set = ShortcutSet::create([
'id' => 'llama_set',
'label' => 'Llama Set',
]);
$set->save();
return $set;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/shortcut_set/shortcut_set/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'shortcut_set--shortcut_set',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'label' => 'Llama Set',
'status' => TRUE,
'langcode' => 'en',
'dependencies' => [],
'drupal_internal__id' => 'llama_set',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,204 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\shortcut\Entity\Shortcut;
use Drupal\shortcut\Entity\ShortcutSet;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "Shortcut" content entity type.
*
* @group jsonapi
*/
class ShortcutTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['comment', 'shortcut'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'shortcut';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'shortcut--default';
/**
* {@inheritdoc}
*
* @var \Drupal\shortcut\ShortcutInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['access shortcuts', 'customize shortcut links']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$shortcut = Shortcut::create([
'shortcut_set' => 'default',
'title' => t('Comments'),
'weight' => -20,
'link' => [
'uri' => 'internal:/user/logout',
],
]);
$shortcut->save();
return $shortcut;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/shortcut/default/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'shortcut--default',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'title' => 'Comments',
'link' => [
'uri' => 'internal:/user/logout',
'title' => NULL,
'options' => [],
],
'langcode' => 'en',
'default_langcode' => TRUE,
'weight' => -20,
'drupal_internal__id' => (int) $this->entity->id(),
],
'relationships' => [
'shortcut_set' => [
'data' => [
'type' => 'shortcut_set--shortcut_set',
'id' => ShortcutSet::load('default')->uuid(),
],
'links' => [
'related' => ['href' => $self_url . '/shortcut_set'],
'self' => ['href' => $self_url . '/relationships/shortcut_set'],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'shortcut--default',
'attributes' => [
'title' => 'Comments',
'link' => [
'uri' => 'internal:/',
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The shortcut set must be the currently displayed set for the user and the user must have 'access shortcuts' AND 'customize shortcut links' permissions.";
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$label_field_name = 'title';
// Verify the expected behavior in the common case: default shortcut set.
$this->grantPermissionsToTestedRole(['customize shortcut links']);
$this->doTestCollectionFilterAccessBasedOnPermissions($label_field_name, 'access shortcuts');
$alternate_shortcut_set = ShortcutSet::create([
'id' => 'alternate',
'label' => 'Alternate',
]);
$alternate_shortcut_set->save();
$this->entity->shortcut_set = $alternate_shortcut_set->id();
$this->entity->save();
$collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
$collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// No results because the current user does not have access to shortcuts
// not in the user's assigned set or the default set.
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// Assign the alternate shortcut set to the current user.
$this->container->get('entity_type.manager')->getStorage('shortcut_set')->assignUser($alternate_shortcut_set, $this->account);
// 1 result because the alternate shortcut set is now assigned to the
// current user.
$response = $this->request('GET', $collection_filter_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
}
/**
* {@inheritdoc}
*/
protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
$cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
if ($filtered) {
$cacheability->addCacheContexts(['user']);
}
return $cacheability;
}
}
@@ -0,0 +1,486 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Url;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "Term" content entity type.
*
* @group jsonapi
*/
class TermTest extends ResourceTestBase {
use CommonCollectionFilterAccessTestPatternsTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['taxonomy', 'path'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'taxonomy_term';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'taxonomy_term--camelids';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'changed' => NULL,
];
/**
* {@inheritdoc}
*
* @var \Drupal\taxonomy\TermInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access content']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['create terms in camelids']);
break;
case 'PATCH':
// Grant the 'create url aliases' permission to test the case when
// the path field is accessible, see
// \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase
// for a negative test.
$this->grantPermissionsToTestedRole(['edit terms in camelids', 'create url aliases']);
break;
case 'DELETE':
$this->grantPermissionsToTestedRole(['delete terms in camelids']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$vocabulary = Vocabulary::load('camelids');
if (!$vocabulary) {
// Create a "Camelids" vocabulary.
$vocabulary = Vocabulary::create([
'name' => 'Camelids',
'vid' => 'camelids',
]);
$vocabulary->save();
}
// Create a "Llama" taxonomy term.
$term = Term::create(['vid' => $vocabulary->id()])
->setName('Llama')
->setDescription("It is a little known fact that llamas cannot count higher than seven.")
->setChangedTime(123456789)
->set('path', '/llama');
$term->save();
return $term;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/taxonomy_term/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
// We test with multiple parent terms, and combinations thereof.
// @see ::createEntity()
// @see ::testGetIndividual()
// @see ::testGetIndividualTermWithParent()
// @see ::providerTestGetIndividualTermWithParent()
$parent_term_ids = [];
for ($i = 0; $i < $this->entity->get('parent')->count(); $i++) {
$parent_term_ids[$i] = (int) $this->entity->get('parent')[$i]->target_id;
}
$expected_parent_normalization = FALSE;
switch ($parent_term_ids) {
case [0]:
$expected_parent_normalization = [
'data' => [
[
'id' => 'virtual',
'type' => 'taxonomy_term--camelids',
'meta' => [
'links' => [
'help' => [
'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual',
'meta' => [
'about' => "Usage and meaning of the 'virtual' resource identifier.",
],
],
],
],
],
],
'links' => [
'related' => ['href' => $self_url . '/parent'],
'self' => ['href' => $self_url . '/relationships/parent'],
],
];
break;
case [2]:
$expected_parent_normalization = [
'data' => [
[
'id' => Term::load(2)->uuid(),
'type' => 'taxonomy_term--camelids',
],
],
'links' => [
'related' => ['href' => $self_url . '/parent'],
'self' => ['href' => $self_url . '/relationships/parent'],
],
];
break;
case [0, 2]:
$expected_parent_normalization = [
'data' => [
[
'id' => 'virtual',
'type' => 'taxonomy_term--camelids',
'meta' => [
'links' => [
'help' => [
'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual',
'meta' => [
'about' => "Usage and meaning of the 'virtual' resource identifier.",
],
],
],
],
],
[
'id' => Term::load(2)->uuid(),
'type' => 'taxonomy_term--camelids',
],
],
'links' => [
'related' => ['href' => $self_url . '/parent'],
'self' => ['href' => $self_url . '/relationships/parent'],
],
];
break;
case [3, 2]:
$expected_parent_normalization = [
'data' => [
[
'id' => Term::load(3)->uuid(),
'type' => 'taxonomy_term--camelids',
],
[
'id' => Term::load(2)->uuid(),
'type' => 'taxonomy_term--camelids',
],
],
'links' => [
'related' => ['href' => $self_url . '/parent'],
'self' => ['href' => $self_url . '/relationships/parent'],
],
];
break;
}
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'taxonomy_term--camelids',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'default_langcode' => TRUE,
'description' => [
'value' => 'It is a little known fact that llamas cannot count higher than seven.',
'format' => NULL,
'processed' => "<p>It is a little known fact that llamas cannot count higher than seven.</p>\n",
],
'langcode' => 'en',
'name' => 'Llama',
'path' => [
'alias' => '/llama',
'pid' => 1,
'langcode' => 'en',
],
'weight' => 0,
'drupal_internal__tid' => 1,
'status' => TRUE,
'drupal_internal__revision_id' => 1,
'revision_created' => (new \DateTime())->setTimestamp($this->entity->getRevisionCreationTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'revision_log_message' => NULL,
// @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
'revision_translation_affected' => TRUE,
],
'relationships' => [
'parent' => $expected_parent_normalization,
'vid' => [
'data' => [
'id' => Vocabulary::load('camelids')->uuid(),
'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
],
'links' => [
'related' => ['href' => $self_url . '/vid'],
'self' => ['href' => $self_url . '/relationships/vid'],
],
],
'revision_user' => [
'data' => NULL,
'links' => [
'related' => [
'href' => $self_url . '/revision_user',
],
'self' => [
'href' => $self_url . '/relationships/revision_user',
],
],
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) {
$data = parent::getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
if ($relationship_field_name === 'parent') {
$data = [
0 => [
'id' => 'virtual',
'type' => 'taxonomy_term--camelids',
'meta' => [
'links' => [
'help' => [
'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual',
'meta' => [
'about' => "Usage and meaning of the 'virtual' resource identifier.",
],
],
],
],
],
];
}
return $data;
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'taxonomy_term--camelids',
'attributes' => [
'name' => 'Dramallama',
'description' => [
'value' => 'Dramallamas are the coolest camelids.',
'format' => NULL,
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access content' permission is required and the taxonomy term must be published.";
case 'POST':
return "The following permissions are required: 'create terms in camelids' OR 'administer taxonomy'.";
case 'PATCH':
return "The following permissions are required: 'edit terms in camelids' OR 'administer taxonomy'.";
case 'DELETE':
return "The following permissions are required: 'delete terms in camelids' OR 'administer taxonomy'.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
$cacheability = parent::getExpectedUnauthorizedAccessCacheability();
$cacheability->addCacheableDependency($this->entity);
return $cacheability;
}
/**
* Tests PATCHing a term's path.
*
* For a negative test, see the similar test coverage for Node.
*
* @see \Drupal\Tests\jsonapi\Functional\NodeTest::testPatchPath()
* @see \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase::testPatchPath()
*/
public function testPatchPath() {
$this->setUpAuthorization('GET');
$this->setUpAuthorization('PATCH');
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// GET term's current normalization.
$response = $this->request('GET', $url, $request_options);
$normalization = Json::decode((string) $response->getBody());
// Change term's path alias.
$normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world';
// Create term PATCH request.
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// PATCH request: 200.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response);
$updated_normalization = Json::decode((string) $response->getBody());
$this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']);
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
$tags = parent::getExpectedCacheTags($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('description', $sparse_fieldset)) {
$tags = Cache::mergeTags($tags, ['config:filter.format.plain_text', 'config:filter.settings']);
}
return $tags;
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
$contexts = parent::getExpectedCacheContexts($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('description', $sparse_fieldset)) {
$contexts = Cache::mergeContexts($contexts, ['languages:language_interface', 'theme']);
}
return $contexts;
}
/**
* Tests GETting a term with a parent term other than the default <root> (0).
*
* @see ::getExpectedNormalizedEntity()
*
* @dataProvider providerTestGetIndividualTermWithParent
*/
public function testGetIndividualTermWithParent(array $parent_term_ids) {
// Create all possible parent terms.
Term::create(['vid' => Vocabulary::load('camelids')->id()])
->setName('Lamoids')
->save();
Term::create(['vid' => Vocabulary::load('camelids')->id()])
->setName('Wimoids')
->save();
// Modify the entity under test to use the provided parent terms.
$this->entity->set('parent', $parent_term_ids)->save();
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$this->setUpAuthorization('GET');
$response = $this->request('GET', $url, $request_options);
$this->assertSameDocument($this->getExpectedDocument(), Json::decode($response->getBody()));
}
/**
* Data provider for ::testGetIndividualTermWithParent().
*/
public function providerTestGetIndividualTermWithParent() {
return [
'root parent: [0] (= no parent)' => [
[0],
],
'non-root parent: [2]' => [
[2],
],
'multiple parents: [0,2] (root + non-root parent)' => [
[0, 2],
],
'multiple parents: [3,2] (both non-root parents)' => [
[3, 2],
],
];
}
/**
* {@inheritdoc}
*/
public function testRelated() {
$this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
$this->doTestCollectionFilterAccessBasedOnPermissions('name', 'access content');
}
}
@@ -0,0 +1,121 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Tests\BrowserTestBase;
/**
* Checks that all core content/config entity types have JSON:API test coverage.
*
* @group jsonapi
*/
class TestCoverageTest extends BrowserTestBase {
/**
* Entity definitions array.
*
* @var array
*/
protected $definitions;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$all_modules = \Drupal::service('extension.list.module')->getList();
$stable_core_modules = array_filter($all_modules, function ($module) {
// Filter out contrib, hidden, testing, 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['package'] !== 'Core (Experimental)';
});
$this->container->get('module_installer')->install(array_keys($stable_core_modules));
$this->rebuildContainer();
$this->definitions = $this->container->get('entity_type.manager')->getDefinitions();
// Entity types marked as "internal" are not exposed by JSON:API 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 entity types have JSON:API test coverage.
*/
public function testEntityTypeRestTestCoverage() {
$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];
$possible_paths = [
'Drupal\Tests\jsonapi\Functional\CLASSTest',
'\Drupal\Tests\\' . $module_name . '\Functional\Jsonapi\CLASSTest',
];
foreach ($possible_paths as $path) {
$missing_tests = [];
$class = str_replace('CLASS', $class_name, $path);
if (class_exists($class)) {
continue 2;
}
$missing_tests[] = $class;
}
if (!empty($missing_tests)) {
$missing_tests_list = implode(', ', $missing_tests);
$problems[] = "$entity_type_id: $class_name ($class_name_full) (expected tests: $missing_tests_list)";
}
}
$all = count($this->definitions);
$good = $all - count($problems);
$this->assertSame([], $problems, $this->getLlamaMessage($good, $all));
}
/**
* Message from Llama.
*
* @param int $g
* A count of entities with test coverage.
* @param int $a
* A count of all entities.
*
* @return string
* An information about progress of REST test coverage.
*/
protected function getLlamaMessage($g, $a) {
return "
_________________________
/ Hi! \\
| It's llame to not have |
| complete JSON:API tests! |
| |
| Progress: $g/$a. |
| _________________________/
|/
// o
l'>
ll
llama
|| ||
'' ''
";
}
}
@@ -0,0 +1,147 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\tour\Entity\Tour;
/**
* JSON:API integration test for the "Tour" config entity type.
*
* @group jsonapi
*/
class TourTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['tour'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'tour';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'tour--tour';
/**
* {@inheritdoc}
*
* @var \Drupal\tour\TourInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['access tour']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$tour = Tour::create([
'id' => 'tour-llama',
'label' => 'Llama tour',
'langcode' => 'en',
'module' => 'tour',
'routes' => [
[
'route_name' => '<front>',
],
],
'tips' => [
'tour-llama-1' => [
'id' => 'tour-llama-1',
'plugin' => 'text',
'label' => 'Llama',
'body' => 'Who handle the awesomeness of llamas?',
'weight' => 100,
'attributes' => [
'data-id' => 'tour-llama-1',
],
],
],
]);
$tour->save();
return $tour;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/tour/tour/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'tour--tour',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [],
'label' => 'Llama tour',
'langcode' => 'en',
'module' => 'tour',
'routes' => [
[
'route_name' => '<front>',
],
],
'status' => TRUE,
'tips' => [
'tour-llama-1' => [
'id' => 'tour-llama-1',
'plugin' => 'text',
'label' => 'Llama',
'body' => 'Who handle the awesomeness of llamas?',
'weight' => 100,
'attributes' => [
'data-id' => 'tour-llama-1',
],
],
],
'drupal_internal__id' => 'tour-llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The following permissions are required: 'access tour' OR 'administer site configuration'.";
}
}
@@ -0,0 +1,50 @@
<?php
namespace Drupal\Tests\jsonapi\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Tests that existing sites have the new read-only mode to "off".
*
* @see jsonapi_update_8701()
* @see https://www.drupal.org/project/jsonapi/issues/3039568
*
* @group jsonapi
* @group Update
* @group legacy
*/
class ReadOnlyModeUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['jsonapi'];
/**
* {@inheritdoc}
*/
public function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
DRUPAL_ROOT . '/core/modules/system/tests/fixtures/update/drupal-8.bare.standard.php.gz',
__DIR__ . '/../../../fixtures/update/drupal-8.jsonapi-jsonapi_update_8701.php',
];
}
/**
* Tests jsonapi_update_8701().
*/
public function testBcReadOnlyModeSettingAdded() {
// Make sure we have the expected values before the update.
$jsonapi_settings = $this->config('jsonapi.settings');
$this->assertFalse(array_key_exists('read_only', $jsonapi_settings->getRawData()));
$this->runUpdates();
// Make sure we have the expected values after the update.
$jsonapi_settings = $this->config('jsonapi.settings');
$this->assertTrue(array_key_exists('read_only', $jsonapi_settings->getRawData()));
$this->assertFalse($jsonapi_settings->get('read_only'));
}
}
@@ -0,0 +1,611 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\node\Entity\Node;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
/**
* JSON:API integration test for the "User" content entity type.
*
* @group jsonapi
*/
class UserTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['user', 'jsonapi_test_user'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'user';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'user--user';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'changed' => NULL,
];
/**
* {@inheritdoc}
*/
protected static $anonymousUsersCanViewLabels = TRUE;
/**
* {@inheritdoc}
*
* @var \Drupal\taxonomy\TermInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected static $labelFieldName = 'display_name';
/**
* {@inheritdoc}
*/
protected static $firstCreatedEntityId = 4;
/**
* {@inheritdoc}
*/
protected static $secondCreatedEntityId = 5;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
// @todo Remove this in
$this->grantPermissionsToTestedRole(['access content']);
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access user profiles']);
break;
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer users']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Llama" user.
$user = User::create(['created' => 123456789]);
$user->setUsername('Llama')
->setChangedTime(123456789)
->activate()
->save();
return $user;
}
/**
* {@inheritdoc}
*/
protected function createAnotherEntity($key) {
/** @var \Drupal\user\UserInterface $user */
$user = $this->getEntityDuplicate($this->entity, $key);
$user->setUsername($user->label() . '_' . $key);
$user->setEmail("$key@example.com");
$user->save();
return $user;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/user/user/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'user--user',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'display_name' => 'Llama',
'created' => '1973-11-29T21:33:09+00:00',
'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'default_langcode' => TRUE,
'langcode' => 'en',
'name' => 'Llama',
'drupal_internal__uid' => 3,
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
$cache_contexts = parent::getExpectedCacheContexts($sparse_fieldset);
if ($sparse_fieldset === NULL || in_array('mail', $sparse_fieldset)) {
$cache_contexts = Cache::mergeContexts($cache_contexts, ['user']);
}
return $cache_contexts;
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
return [
'data' => [
'type' => 'user--user',
'attributes' => [
'name' => 'Dramallama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access user profiles' permission is required and the user must be active.";
case 'PATCH':
return "Users can only update their own account, unless they have the 'administer users' permission.";
case 'DELETE':
return "The 'cancel account' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* Tests PATCHing security-sensitive base fields of the logged in account.
*/
public function testPatchDxForSecuritySensitiveBaseFields() {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['entity' => $this->account->uuid()]);
/* $url = $this->account->toUrl('jsonapi'); */
// Since this test must be performed by the user that is being modified,
// we must use $this->account, not $this->entity.
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$response = $this->request('GET', $url, $request_options);
$original_normalization = Json::decode((string) $response->getBody());
// Test case 1: changing email.
$normalization = $original_normalization;
$normalization['data']['attributes']['mail'] = 'new-email@example.com';
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// DX: 405 when read-only mode is enabled.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response);
$this->assertSame(['GET'], $response->getHeader('Allow'));
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// DX: 422 when changing email without providing the password.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(422, 'mail: Your current password is missing or incorrect; it\'s required to change the Email.', NULL, $response, '/data/attributes/mail');
$normalization['data']['attributes']['pass']['existing'] = 'wrong';
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// DX: 422 when changing email while providing a wrong password.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(422, 'mail: Your current password is missing or incorrect; it\'s required to change the Email.', NULL, $response, '/data/attributes/mail');
$normalization['data']['attributes']['pass']['existing'] = $this->account->passRaw;
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// 200 for well-formed request.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response);
// Test case 2: changing password.
$normalization = Json::decode((string) $response->getBody());
$normalization['data']['attributes']['mail'] = 'new-email@example.com';
$new_password = $this->randomString();
$normalization['data']['attributes']['pass']['value'] = $new_password;
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// DX: 422 when changing password without providing the current password.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(422, 'pass: Your current password is missing or incorrect; it\'s required to change the Password.', NULL, $response, '/data/attributes/pass');
$normalization['data']['attributes']['pass']['existing'] = $this->account->passRaw;
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// 200 for well-formed request.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response);
// Verify that we can log in with the new password.
$this->assertRpcLogin($this->account->getAccountName(), $new_password);
// Update password in $this->account, prepare for future requests.
$this->account->passRaw = $new_password;
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// Test case 3: changing name.
$normalization = Json::decode((string) $response->getBody());
$normalization['data']['attributes']['mail'] = 'new-email@example.com';
$normalization['data']['attributes']['pass']['existing'] = $new_password;
$normalization['data']['attributes']['name'] = 'Cooler Llama';
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// DX: 403 when modifying username without required permission.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(403, 'The current user is not allowed to PATCH the selected field (name).', $url, $response, '/data/attributes/name');
$this->grantPermissionsToTestedRole(['change own username']);
// 200 for well-formed request.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response);
// Verify that we can log in with the new username.
$this->assertRpcLogin('Cooler Llama', $new_password);
}
/**
* Verifies that logging in with the given username and password works.
*
* @param string $username
* The username to log in with.
* @param string $password
* The password to log in with.
*/
protected function assertRpcLogin($username, $password) {
$request_body = [
'name' => $username,
'pass' => $password,
];
$request_options = [
RequestOptions::HEADERS => [],
RequestOptions::BODY => Json::encode($request_body),
];
$response = $this->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json'), $request_options);
$this->assertSame(200, $response->getStatusCode());
}
/**
* Tests PATCHing security-sensitive base fields to change other users.
*/
public function testPatchSecurityOtherUser() {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['entity' => $this->account->uuid()]);
/* $url = $this->account->toUrl('jsonapi'); */
$original_normalization = $this->normalize($this->account, $url);
// Since this test must be performed by the user that is being modified,
// we must use $this->account, not $this->entity.
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$normalization = $original_normalization;
$normalization['data']['attributes']['mail'] = 'new-email@example.com';
$request_options[RequestOptions::BODY] = Json::encode($normalization);
// DX: 405 when read-only mode is enabled.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response);
$this->assertSame(['GET'], $response->getHeader('Allow'));
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
// Try changing user 1's email.
$user1 = $original_normalization;
$user1['data']['attributes']['mail'] = 'another_email_address@example.com';
$user1['data']['attributes']['uid'] = 1;
$user1['data']['attributes']['name'] = 'another_user_name';
$user1['data']['attributes']['pass']['existing'] = $this->account->passRaw;
$request_options[RequestOptions::BODY] = Json::encode($user1);
$response = $this->request('PATCH', $url, $request_options);
// Ensure the email address has not changed.
$this->assertEquals('admin@example.com', $this->entityStorage->loadUnchanged(1)->getEmail());
$this->assertResourceErrorResponse(403, 'The current user is not allowed to PATCH the selected field (uid). The entity ID cannot be changed.', $url, $response, '/data/attributes/uid');
}
/**
* Tests GETting privacy-sensitive base fields.
*/
public function testGetMailFieldOnlyVisibleToOwner() {
// Create user B, with the same roles (and hence permissions) as user A.
$user_a = $this->account;
$pass = user_password();
$user_b = User::create([
'name' => 'sibling-of-' . $user_a->getAccountName(),
'mail' => 'sibling-of-' . $user_a->getAccountName() . '@example.com',
'pass' => $pass,
'status' => 1,
'roles' => $user_a->getRoles(),
]);
$user_b->save();
$user_b->passRaw = $pass;
// Grant permission to role that both users use.
$this->grantPermissionsToTestedRole(['access user profiles']);
$collection_url = Url::fromRoute('jsonapi.user--user.collection', [], ['query' => ['sort' => 'drupal_internal__uid']]);
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$user_a_url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['entity' => $user_a->uuid()]);
/* $user_a_url = $user_a->toUrl('jsonapi'); */
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// Viewing user A as user A: "mail" field is accessible.
$response = $this->request('GET', $user_a_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertArrayHasKey('mail', $doc['data']['attributes']);
// Also when looking at the collection.
$response = $this->request('GET', $collection_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertSame($user_a->uuid(), $doc['data']['2']['id']);
$this->assertArrayHasKey('mail', $doc['data'][2]['attributes'], "Own user--user resource's 'mail' field is visible.");
$this->assertSame($user_b->uuid(), $doc['data'][count($doc['data']) - 1]['id']);
$this->assertArrayNotHasKey('mail', $doc['data'][count($doc['data']) - 1]['attributes']);
// Now request the same URLs, but as user B (same roles/permissions).
$this->account = $user_b;
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// Viewing user A as user B: "mail" field should be inaccessible.
$response = $this->request('GET', $user_a_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertArrayNotHasKey('mail', $doc['data']['attributes']);
// Also when looking at the collection.
$response = $this->request('GET', $collection_url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertSame($user_a->uuid(), $doc['data']['2']['id']);
$this->assertArrayNotHasKey('mail', $doc['data'][2]['attributes']);
$this->assertSame($user_b->uuid(), $doc['data'][count($doc['data']) - 1]['id']);
$this->assertArrayHasKey('mail', $doc['data'][count($doc['data']) - 1]['attributes']);
}
/**
* Test good error DX when trying to filter users by role.
*/
public function testQueryInvolvingRoles() {
$this->setUpAuthorization('GET');
$collection_url = Url::fromRoute('jsonapi.user--user.collection', [], ['query' => ['filter[roles.id][value]' => 'e9b1de3f-9517-4c27-bef0-0301229de792']]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// The 'administer users' permission is required to filter by role entities.
$this->grantPermissionsToTestedRole(['administer users']);
$response = $this->request('GET', $collection_url, $request_options);
$expected_cache_contexts = ['url.path', 'url.query_args:filter', 'url.site'];
$this->assertResourceErrorResponse(400, "Filtering on config entities is not supported by Drupal's entity API. You tried to filter on a Role config entity.", $collection_url, $response, FALSE, ['4xx-response', 'http_response'], $expected_cache_contexts, FALSE, 'MISS');
}
/**
* Tests that the collection contains the anonymous user.
*/
public function testCollectionContainsAnonymousUser() {
$url = Url::fromRoute('jsonapi.user--user.collection', [], ['query' => ['sort' => 'drupal_internal__uid']]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$response = $this->request('GET', $url, $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(4, $doc['data']);
$this->assertSame(User::load(0)->uuid(), $doc['data'][0]['id']);
$this->assertSame('User 0', $doc['data'][0]['attributes']['display_name']);
}
/**
* {@inheritdoc}
*/
public function testCollectionFilterAccess() {
// Set up data model.
$this->assertTrue($this->container->get('module_installer')->install(['node'], TRUE), 'Installed modules.');
FieldStorageConfig::create([
'entity_type' => static::$entityTypeId,
'field_name' => 'field_favorite_animal',
'type' => 'string',
])
->setCardinality(1)
->save();
FieldConfig::create([
'entity_type' => static::$entityTypeId,
'field_name' => 'field_favorite_animal',
'bundle' => 'user',
])
->setLabel('Test field')
->setTranslatable(FALSE)
->save();
$this->drupalCreateContentType(['type' => 'x']);
$this->rebuildAll();
$this->grantPermissionsToTestedRole(['access content']);
// Create data.
$user_a = User::create([])->setUsername('A')->activate();
$user_a->save();
$user_b = User::create([])->setUsername('B')->set('field_favorite_animal', 'stegosaurus')->block();
$user_b->save();
$node_a = Node::create(['type' => 'x'])->setTitle('Owned by A')->setOwner($user_a);
$node_a->save();
$node_b = Node::create(['type' => 'x'])->setTitle('Owned by B')->setOwner($user_b);
$node_b->save();
$node_anon_1 = Node::create(['type' => 'x'])->setTitle('Owned by anon #1')->setOwnerId(0);
$node_anon_1->save();
$node_anon_2 = Node::create(['type' => 'x'])->setTitle('Owned by anon #2')->setOwnerId(0);
$node_anon_2->save();
$node_auth_1 = Node::create(['type' => 'x'])->setTitle('Owned by auth #1')->setOwner($this->account);
$node_auth_1->save();
$favorite_animal_test_url = Url::fromRoute('jsonapi.user--user.collection')->setOption('query', ['filter[field_favorite_animal]' => 'stegosaurus']);
// Test.
$collection_url = Url::fromRoute('jsonapi.node--x.collection');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
// ?filter[uid.id]=OWN_UUID requires no permissions: 1 result.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.id]' => $this->account->uuid()]), $request_options);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
$this->assertSame($node_auth_1->uuid(), $doc['data'][0]['id']);
// ?filter[uid.id]=ANONYMOUS_UUID: 0 results.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.id]' => User::load(0)->uuid()]), $request_options);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// ?filter[uid.name]=A: 0 results.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'A']), $request_options);
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// /jsonapi/user/user?filter[field_favorite_animal]: 0 results.
$response = $this->request('GET', $favorite_animal_test_url, $request_options);
$this->assertSame(200, $response->getStatusCode());
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// Grant "view" permission.
$this->grantPermissionsToTestedRole(['access user profiles']);
// ?filter[uid.id]=ANONYMOUS_UUID: 0 results.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.id]' => User::load(0)->uuid()]), $request_options);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// ?filter[uid.name]=A: 1 result since user A is active.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'A']), $request_options);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
$this->assertSame($node_a->uuid(), $doc['data'][0]['id']);
// ?filter[uid.name]=B: 0 results since user B is blocked.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'B']), $request_options);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// /jsonapi/user/user?filter[field_favorite_animal]: 0 results.
$response = $this->request('GET', $favorite_animal_test_url, $request_options);
$this->assertSame(200, $response->getStatusCode());
$doc = Json::decode((string) $response->getBody());
$this->assertCount(0, $doc['data']);
// Grant "admin" permission.
$this->grantPermissionsToTestedRole(['administer users']);
// ?filter[uid.name]=B: 1 result.
$response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'B']), $request_options);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
$this->assertSame($node_b->uuid(), $doc['data'][0]['id']);
// /jsonapi/user/user?filter[field_favorite_animal]: 1 result.
$response = $this->request('GET', $favorite_animal_test_url, $request_options);
$this->assertSame(200, $response->getStatusCode());
$doc = Json::decode((string) $response->getBody());
$this->assertCount(1, $doc['data']);
$this->assertSame($user_b->uuid(), $doc['data'][0]['id']);
}
/**
* Tests users with altered display names.
*/
public function testResaveAccountName() {
$this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
$this->setUpAuthorization('PATCH');
$original_name = $this->entity->get('name')->value;
$url = Url::fromRoute('jsonapi.user--user.individual', ['entity' => $this->entity->uuid()]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
$response = $this->request('GET', $url, $request_options);
// Send the unchanged data back.
$request_options[RequestOptions::BODY] = (string) $response->getBody();
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
$response = $this->request('PATCH', $url, $request_options);
$this->assertEquals(200, $response->getStatusCode());
// Load the user entity again, make sure the name was not changed.
$this->entityStorage->resetCache();
$updated_user = $this->entityStorage->load($this->entity->id());
$this->assertEquals($original_name, $updated_user->get('name')->value);
}
/**
* {@inheritdoc}
*/
protected function getModifiedEntityForPostTesting() {
$modified = parent::getModifiedEntityForPostTesting();
$modified['data']['attributes']['name'] = $this->randomMachineName();
return $modified;
}
/**
* {@inheritdoc}
*/
protected function makeNormalizationInvalid(array $document, $entity_key) {
if ($entity_key === 'label') {
$document['data']['attributes']['name'] = [
0 => $document['data']['attributes']['name'],
1 => 'Second Title',
];
return $document;
}
return parent::makeNormalizationInvalid($document, $entity_key);
}
}
@@ -0,0 +1,126 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\views\Entity\View;
/**
* JSON:API integration test for the "View" config entity type.
*
* @group jsonapi
*/
class ViewTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['views'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'view';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'view--view';
/**
* {@inheritdoc}
*
* @var \Drupal\views\ViewEntityInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer views']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$view = View::create([
'id' => 'test_rest',
'label' => 'Test REST',
]);
$view->save();
return $view;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/view/view/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'view--view',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'base_field' => 'nid',
'base_table' => 'node',
'dependencies' => [],
'description' => '',
'display' => [
'default' => [
'display_plugin' => 'default',
'id' => 'default',
'display_title' => 'Master',
'position' => 0,
'display_options' => [
'display_extenders' => [],
],
'cache_metadata' => [
'max-age' => -1,
'contexts' => [
'languages:language_interface',
'url.query_args',
],
'tags' => [],
],
],
],
'label' => 'Test REST',
'langcode' => 'en',
'module' => 'views',
'status' => TRUE,
'tag' => '',
'drupal_internal__id' => 'test_rest',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,115 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\taxonomy\Entity\Vocabulary;
/**
* JSON:API integration test for the "vocabulary" config entity type.
*
* @group jsonapi
*/
class VocabularyTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['taxonomy'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'taxonomy_vocabulary';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'taxonomy_vocabulary--taxonomy_vocabulary';
/**
* {@inheritdoc}
*
* @var \Drupal\taxonomy\VocabularyInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer taxonomy']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$vocabulary = Vocabulary::create([
'name' => 'Llama',
'vid' => 'llama',
]);
$vocabulary->save();
return $vocabulary;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/taxonomy_vocabulary/taxonomy_vocabulary/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [],
'name' => 'Llama',
'description' => NULL,
'weight' => 0,
'drupal_internal__vid' => 'llama',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
if ($method === 'GET') {
return "The following permissions are required: 'access taxonomy overview' OR 'administer taxonomy'.";
}
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
@@ -0,0 +1,131 @@
<?php
namespace Drupal\Tests\jsonapi\Functional;
use Drupal\Core\Url;
use Drupal\workflows\Entity\Workflow;
/**
* JSON:API integration test for the "Workflow" config entity type.
*
* @group jsonapi
*/
class WorkflowTest extends ResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['workflows', 'workflow_type_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'workflow';
/**
* {@inheritdoc}
*/
protected static $resourceTypeName = 'workflow--workflow';
/**
* {@inheritdoc}
*
* @var \Drupal\shortcut\ShortcutSetInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer workflows']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$workflow = Workflow::create([
'id' => 'rest_workflow',
'label' => 'REST Worklow',
'type' => 'workflow_type_complex_test',
]);
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published');
$configuration = $workflow->getTypePlugin()->getConfiguration();
$configuration['example_setting'] = 'foo';
$configuration['states']['draft']['extra'] = 'bar';
$workflow->getTypePlugin()->setConfiguration($configuration);
$workflow->save();
return $workflow;
}
/**
* {@inheritdoc}
*/
protected function getExpectedDocument() {
$self_url = Url::fromUri('base:/jsonapi/workflow/workflow/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
return [
'jsonapi' => [
'meta' => [
'links' => [
'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
],
],
'version' => '1.0',
],
'links' => [
'self' => ['href' => $self_url],
],
'data' => [
'id' => $this->entity->uuid(),
'type' => 'workflow--workflow',
'links' => [
'self' => ['href' => $self_url],
],
'attributes' => [
'dependencies' => [
'module' => [
'workflow_type_test',
],
],
'label' => 'REST Worklow',
'langcode' => 'en',
'status' => TRUE,
'workflow_type' => 'workflow_type_complex_test',
'type_settings' => [
'states' => [
'draft' => [
'extra' => 'bar',
'label' => 'Draft',
'weight' => 0,
],
'published' => [
'label' => 'Published',
'weight' => 1,
],
],
'transitions' => [],
'example_setting' => 'foo',
],
'drupal_internal__id' => 'rest_workflow',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getPostDocument() {
// @todo Update in https://www.drupal.org/node/2300677.
}
}
@@ -0,0 +1,392 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\Context;
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
use Drupal\entity_test\Entity\EntityTestBundle;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
/**
* @coversDefaultClass \Drupal\jsonapi\Context\FieldResolver
* @group jsonapi
*
* @internal
*/
class FieldResolverTest extends JsonapiKernelTestBase {
public static $modules = [
'entity_test',
'jsonapi_test_field_aliasing',
'jsonapi_test_field_filter_access',
'serialization',
'field',
'text',
'user',
];
/**
* The subject under test.
*
* @var \Drupal\jsonapi\Context\FieldResolver
*/
protected $sut;
/**
* The JSON:API resource type repository.
*
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
*/
protected $resourceTypeRepository;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('entity_test_with_bundle');
$this->sut = \Drupal::service('jsonapi.field_resolver');
$this->makeBundle('bundle1');
$this->makeBundle('bundle2');
$this->makeBundle('bundle3');
$this->makeField('string', 'field_test1', 'entity_test_with_bundle', ['bundle1']);
$this->makeField('string', 'field_test2', 'entity_test_with_bundle', ['bundle1']);
$this->makeField('string', 'field_test3', 'entity_test_with_bundle', ['bundle2', 'bundle3']);
// Provides entity reference fields.
$settings = ['target_type' => 'entity_test_with_bundle'];
$this->makeField('entity_reference', 'field_test_ref1', 'entity_test_with_bundle', ['bundle1'], $settings, [
'handler_settings' => [
'target_bundles' => ['bundle2', 'bundle3'],
],
]);
$this->makeField('entity_reference', 'field_test_ref2', 'entity_test_with_bundle', ['bundle1'], $settings);
$this->makeField('entity_reference', 'field_test_ref3', 'entity_test_with_bundle', ['bundle2', 'bundle3'], $settings);
// Add a field with multiple properties.
$this->makeField('text', 'field_test_text', 'entity_test_with_bundle', ['bundle1', 'bundle2']);
// Add two fields that have different internal names but have the same
// public name.
$this->makeField('entity_reference', 'field_test_alias_a', 'entity_test_with_bundle', ['bundle2'], $settings);
$this->makeField('entity_reference', 'field_test_alias_b', 'entity_test_with_bundle', ['bundle3'], $settings);
$this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
}
/**
* @covers ::resolveInternalEntityQueryPath
* @dataProvider resolveInternalIncludePathProvider
*/
public function testResolveInternalIncludePath($expect, $external_path, $entity_type_id = 'entity_test_with_bundle', $bundle = 'bundle1') {
$path_parts = explode('.', $external_path);
$resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
$this->assertEquals($expect, $this->sut->resolveInternalIncludePath($resource_type, $path_parts));
}
/**
* Provides test cases for resolveInternalEntityQueryPath.
*/
public function resolveInternalIncludePathProvider() {
return [
'entity reference' => [[['field_test_ref2']], 'field_test_ref2'],
'entity reference with multi target bundles' => [[['field_test_ref1']], 'field_test_ref1'],
'entity reference then another entity reference' => [
[['field_test_ref1', 'field_test_ref3']],
'field_test_ref1.field_test_ref3',
],
'entity reference with multiple target bundles, each with different field, but the same public field name' => [
[
['field_test_ref1', 'field_test_alias_a'],
['field_test_ref1', 'field_test_alias_b'],
],
'field_test_ref1.field_test_alias',
],
];
}
/**
* Expects an error when an invalid field is provided for include.
*
* @param string $entity_type
* The entity type for which to test field resolution.
* @param string $bundle
* The entity bundle for which to test field resolution.
* @param string $external_path
* The external field path to resolve.
* @param string $expected_message
* (optional) An expected exception message.
*
* @covers ::resolveInternalIncludePath
* @dataProvider resolveInternalIncludePathErrorProvider
*/
public function testResolveInternalIncludePathError($entity_type, $bundle, $external_path, $expected_message = '') {
$path_parts = explode('.', $external_path);
$this->expectException(CacheableBadRequestHttpException::class);
if (!empty($expected_message)) {
$this->expectExceptionMessage($expected_message);
}
$resource_type = $this->resourceTypeRepository->get($entity_type, $bundle);
$this->sut->resolveInternalIncludePath($resource_type, $path_parts);
}
/**
* Provides test cases for ::testResolveInternalIncludePathError.
*/
public function resolveInternalIncludePathErrorProvider() {
return [
// Should fail because none of these bundles have these fields.
['entity_test_with_bundle', 'bundle1', 'host.fail!!.deep'],
['entity_test_with_bundle', 'bundle2', 'field_test_ref2'],
['entity_test_with_bundle', 'bundle1', 'field_test_ref3'],
// Should fail because the nested fields don't exist on the targeted
// resource types.
['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test1'],
['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test2'],
['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref1'],
['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref2'],
// Should fail because the nested fields is not a valid relationship
// field name.
[
'entity_test_with_bundle', 'bundle1', 'field_test1',
'`field_test1` is not a valid relationship field name.',
],
// Should fail because the nested fields is not a valid include path.
[
'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test3',
'`field_test_ref1.field_test3` is not a valid include path.',
],
];
}
/**
* @covers ::resolveInternalEntityQueryPath
* @dataProvider resolveInternalEntityQueryPathProvider
*/
public function testResolveInternalEntityQueryPath($expect, $external_path, $entity_type_id = 'entity_test_with_bundle', $bundle = 'bundle1') {
$resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
$this->assertEquals($expect, $this->sut->resolveInternalEntityQueryPath($resource_type, $external_path));
}
/**
* Provides test cases for ::testResolveInternalEntityQueryPath.
*/
public function resolveInternalEntityQueryPathProvider() {
return [
'config entity as base' => [
'uuid', 'id', 'entity_test_bundle', 'entity_test_bundle',
],
'config entity as target' => ['type.entity:entity_test_bundle.uuid', 'type.id'],
'primitive field; variation A' => ['field_test1', 'field_test1'],
'primitive field; variation B' => ['field_test2', 'field_test2'],
'entity reference then a primitive field; variation A' => ['field_test_ref2.entity:entity_test_with_bundle.field_test1', 'field_test_ref2.field_test1'],
'entity reference then a primitive field; variation B' => ['field_test_ref2.entity:entity_test_with_bundle.field_test2', 'field_test_ref2.field_test2'],
'entity reference then a complex field with property specifier `value`' => ['field_test_ref2.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref2.field_test_text.value'],
'entity reference then a complex field with property specifier `format`' => ['field_test_ref2.entity:entity_test_with_bundle.field_test_text.format', 'field_test_ref2.field_test_text.format'],
'entity reference then no delta with property specifier `id`' => ['field_test_ref1.entity:entity_test_with_bundle.uuid', 'field_test_ref1.id'],
'entity reference then delta 0 with property specifier `id`' => ['field_test_ref1.0.entity:entity_test_with_bundle.uuid', 'field_test_ref1.0.id'],
'entity reference then delta 1 with property specifier `id`' => ['field_test_ref1.1.entity:entity_test_with_bundle.uuid', 'field_test_ref1.1.id'],
'entity reference then no reference property and a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.field_test_text.value'],
'entity reference then a reference property and a complex field with property specifier `value`' => ['field_test_ref1.entity.field_test_text.value', 'field_test_ref1.entity.field_test_text.value'],
'entity reference then no reference property and a complex field with property specifier `format`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_text.format', 'field_test_ref1.field_test_text.format'],
'entity reference then a reference property and a complex field with property specifier `format`' => ['field_test_ref1.entity.field_test_text.format', 'field_test_ref1.entity.field_test_text.format'],
'entity reference then property specifier `entity:entity_test_with_bundle` then a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.entity:entity_test_with_bundle.field_test_text.value'],
'entity reference with a delta and no reference property then a complex field and property specifier `value`' => ['field_test_ref1.0.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.0.field_test_text.value'],
'entity reference with a delta and a reference property then a complex field and property specifier `value`' => ['field_test_ref1.0.entity.field_test_text.value', 'field_test_ref1.0.entity.field_test_text.value'],
'entity reference with no reference property then another entity reference with no reference property a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.field_test_ref3.field_test_text.value'],
'entity reference with a reference property then another entity reference with no reference property a complex field with property specifier `value`' => ['field_test_ref1.entity.field_test_ref3.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.entity.field_test_ref3.field_test_text.value'],
'entity reference with no reference property then another entity reference with a reference property a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.entity.field_test_text.value', 'field_test_ref1.field_test_ref3.entity.field_test_text.value'],
'entity reference with a reference property then another entity reference with a reference property a complex field with property specifier `value`' => ['field_test_ref1.entity.field_test_ref3.entity.field_test_text.value', 'field_test_ref1.entity.field_test_ref3.entity.field_test_text.value'],
'entity reference with target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on multiple bundles' => [
'field_test_ref1.entity:entity_test_with_bundle.field_test3',
'field_test_ref1.entity:entity_test_with_bundle.field_test3',
],
'entity reference without target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on a single bundle' => [
'field_test_ref2.entity:entity_test_with_bundle.field_test1',
'field_test_ref2.entity:entity_test_with_bundle.field_test1',
],
'entity reference without target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on multiple bundles' => [
'field_test_ref3.entity:entity_test_with_bundle.field_test3',
'field_test_ref3.entity:entity_test_with_bundle.field_test3',
'entity_test_with_bundle', 'bundle2',
],
'entity reference without target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on a single bundle starting from a different resource type' => [
'field_test_ref3.entity:entity_test_with_bundle.field_test2',
'field_test_ref3.entity:entity_test_with_bundle.field_test2',
'entity_test_with_bundle', 'bundle3',
],
'entity reference then property specifier `entity:entity_test_with_bundle` then another entity reference before a primitive field' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.entity:entity_test_with_bundle.field_test2', 'field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.field_test2'],
];
}
/**
* Expects an error when an invalid field is provided for filter and sort.
*
* @param string $entity_type
* The entity type for which to test field resolution.
* @param string $bundle
* The entity bundle for which to test field resolution.
* @param string $external_path
* The external field path to resolve.
* @param string $expected_message
* (optional) An expected exception message.
*
* @covers ::resolveInternalEntityQueryPath
* @dataProvider resolveInternalEntityQueryPathErrorProvider
*/
public function testResolveInternalEntityQueryPathError($entity_type, $bundle, $external_path, $expected_message = '') {
$this->expectException(CacheableBadRequestHttpException::class);
if (!empty($expected_message)) {
$this->expectExceptionMessage($expected_message);
}
$resource_type = $this->resourceTypeRepository->get($entity_type, $bundle);
$this->sut->resolveInternalEntityQueryPath($resource_type, $external_path);
}
/**
* Provides test cases for ::testResolveInternalEntityQueryPathError.
*/
public function resolveInternalEntityQueryPathErrorProvider() {
return [
'nested fields' => [
'entity_test_with_bundle', 'bundle1', 'none.of.these.exist',
],
'field does not exist on bundle' => [
'entity_test_with_bundle', 'bundle2', 'field_test_ref2',
],
'field does not exist on different bundle' => [
'entity_test_with_bundle', 'bundle1', 'field_test_ref3',
],
'field does not exist on targeted bundle' => [
'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test1',
],
'different field does not exist on same targeted bundle' => [
'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test2',
],
'entity reference field does not exist on targeted bundle' => [
'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref1',
],
'different entity reference field does not exist on same targeted bundle' => [
'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref2',
],
'message correctly identifies missing field' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.entity:entity_test_with_bundle.field_test1',
'Invalid nested filtering. The field `field_test1`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test1`, does not exist.',
],
'message correctly identifies different missing field' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.entity:entity_test_with_bundle.field_test2',
'Invalid nested filtering. The field `field_test2`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test2`, does not exist.',
],
'message correctly identifies missing entity reference field' => [
'entity_test_with_bundle', 'bundle2',
'field_test_ref1.entity:entity_test_with_bundle.field_test2',
'Invalid nested filtering. The field `field_test_ref1`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test2`, does not exist.',
],
'entity reference then a complex field with no property specifier' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref2.field_test_text',
'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref2.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
],
'entity reference then no delta with property specifier `target_id`' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.target_id',
'Invalid nested filtering. The property `target_id`, given in the path `field_test_ref1.target_id`, does not exist. Filter by `field_test_ref1`, not `field_test_ref1.target_id` (the JSON:API module elides property names from single-property fields).',
],
'entity reference then delta 0 with property specifier `target_id`' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.0.target_id',
'Invalid nested filtering. The property `target_id`, given in the path `field_test_ref1.0.target_id`, does not exist. Filter by `field_test_ref1.0`, not `field_test_ref1.0.target_id` (the JSON:API module elides property names from single-property fields).',
],
'entity reference then delta 1 with property specifier `target_id`' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.1.target_id',
'Invalid nested filtering. The property `target_id`, given in the path `field_test_ref1.1.target_id`, does not exist. Filter by `field_test_ref1.1`, not `field_test_ref1.1.target_id` (the JSON:API module elides property names from single-property fields).',
],
'entity reference then no reference property then a complex field' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.field_test_text',
'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref1.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
],
'entity reference then reference property then a complex field' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.entity.field_test_text',
'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref1.entity.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
],
'entity reference then property specifier `entity:entity_test_with_bundle` then a complex field' => [
'entity_test_with_bundle', 'bundle1',
'field_test_ref1.entity:entity_test_with_bundle.field_test_text',
'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
],
];
}
/**
* Create a simple bundle.
*
* @param string $name
* The name of the bundle to create.
*/
protected function makeBundle($name) {
EntityTestBundle::create([
'id' => $name,
])->save();
}
/**
* Creates a field for a specified entity type/bundle.
*
* @param string $type
* The field type.
* @param string $name
* The name of the field to create.
* @param string $entity_type
* The entity type to which the field will be attached.
* @param string[] $bundles
* The entity bundles to which the field will be attached.
* @param array $storage_settings
* Custom storage settings for the field.
* @param array $config_settings
* Custom configuration settings for the field.
*/
protected function makeField($type, $name, $entity_type, array $bundles, array $storage_settings = [], array $config_settings = []) {
$storage_config = [
'field_name' => $name,
'type' => $type,
'entity_type' => $entity_type,
'settings' => $storage_settings,
];
FieldStorageConfig::create($storage_config)->save();
foreach ($bundles as $bundle) {
FieldConfig::create([
'field_name' => $name,
'entity_type' => $entity_type,
'bundle' => $bundle,
'settings' => $config_settings,
])->save();
}
}
}
@@ -0,0 +1,229 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\Controller;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\jsonapi\JsonApiResource\Data;
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\Entity\User;
use Drupal\user\RoleInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
/**
* @coversDefaultClass \Drupal\jsonapi\Controller\EntityResource
* @group jsonapi
*
* @internal
*/
class EntityResourceTest extends JsonapiKernelTestBase {
/**
* Static UUIDs to use in testing.
*
* @var array
*/
protected static $nodeUuid = [
1 => '83bc47ad-2c58-45e3-9136-abcdef111111',
2 => '83bc47ad-2c58-45e3-9136-abcdef222222',
3 => '83bc47ad-2c58-45e3-9136-abcdef333333',
4 => '83bc47ad-2c58-45e3-9136-abcdef444444',
];
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'field',
'jsonapi',
'serialization',
'system',
'user',
];
/**
* The user.
*
* @var \Drupal\user\Entity\User
*/
protected $user;
/**
* The node.
*
* @var \Drupal\node\Entity\Node
*/
protected $node;
/**
* The other node.
*
* @var \Drupal\node\Entity\Node
*/
protected $node2;
/**
* An unpublished node.
*
* @var \Drupal\node\Entity\Node
*/
protected $node3;
/**
* A fake request.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* The EntityResource under test.
*
* @var \Drupal\jsonapi\Controller\EntityResource
*/
protected $entityResource;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Add the entity schemas.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
// Add the additional table schemas.
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
NodeType::create([
'type' => 'lorem',
])->save();
$type = NodeType::create([
'type' => 'article',
]);
$type->save();
$this->user = User::create([
'name' => 'user1',
'mail' => 'user@localhost',
'status' => 1,
'roles' => ['test_role_one', 'test_role_two'],
]);
$this->createEntityReferenceField('node', 'article', 'field_relationships', 'Relationship', 'node', 'default', ['target_bundles' => ['article']], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
$this->user->save();
$this->node = Node::create([
'title' => 'dummy_title',
'type' => 'article',
'uid' => $this->user->id(),
'uuid' => static::$nodeUuid[1],
]);
$this->node->save();
$this->node2 = Node::create([
'type' => 'article',
'title' => 'Another test node',
'uid' => $this->user->id(),
'uuid' => static::$nodeUuid[2],
]);
$this->node2->save();
$this->node3 = Node::create([
'type' => 'article',
'title' => 'Unpublished test node',
'uid' => $this->user->id(),
'status' => 0,
'uuid' => static::$nodeUuid[3],
]);
$this->node3->save();
$this->node4 = Node::create([
'type' => 'article',
'title' => 'Test node with related nodes',
'uid' => $this->user->id(),
'field_relationships' => [
['target_id' => $this->node->id()],
['target_id' => $this->node2->id()],
['target_id' => $this->node3->id()],
],
'uuid' => static::$nodeUuid[4],
]);
$this->node4->save();
// Give anonymous users permission to view user profiles, so that we can
// verify the cache tags of cached versions of user profile pages.
array_map(function ($role_id) {
Role::create([
'id' => $role_id,
'permissions' => [
'access user profiles',
'access content',
],
])->save();
}, [RoleInterface::ANONYMOUS_ID, 'test_role_one', 'test_role_two']);
$this->entityResource = $this->createEntityResource();
}
/**
* Creates an instance of the subject under test.
*
* @return \Drupal\jsonapi\Controller\EntityResource
* An EntityResource instance.
*/
protected function createEntityResource() {
return $this->container->get('jsonapi.entity_resource');
}
/**
* @covers ::getCollection
*/
public function testGetPagedCollection() {
$request = Request::create('/jsonapi/node/article');
$request->query = new ParameterBag([
'sort' => 'nid',
'page' => [
'offset' => 1,
'limit' => 1,
],
]);
$entity_resource = $this->createEntityResource();
// Get the response.
$resource_type = $this->container->get('jsonapi.resource_type.repository')->get('node', 'article');
$response = $entity_resource->getCollection($resource_type, $request);
// Assertions.
$this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
$this->assertInstanceOf(Data::class, $response->getResponseData()->getData());
$data = $response->getResponseData()->getData();
$this->assertCount(1, $data);
$this->assertEquals($this->node2->uuid(), $data->toArray()[0]->getId());
$this->assertEquals(['node:2', 'node_list'], $response->getCacheableMetadata()->getCacheTags());
}
/**
* @covers ::getCollection
*/
public function testGetEmptyCollection() {
$request = Request::create('/jsonapi/node/article');
$request->query = new ParameterBag(['filter' => ['id' => 'invalid']]);
// Get the response.
$resource_type = new ResourceType('node', 'article', NULL);
$response = $this->entityResource->getCollection($resource_type, $request);
// Assertions.
$this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
$this->assertInstanceOf(Data::class, $response->getResponseData()->getData());
$this->assertEquals(0, $response->getResponseData()->getData()->count());
$this->assertEquals(['node_list'], $response->getCacheableMetadata()->getCacheTags());
}
}
@@ -0,0 +1,95 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\EventSubscriber;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\KernelTests\KernelTestBase;
use Drupal\user\Entity\User;
use Symfony\Component\HttpKernel\Event\PostResponseEvent;
/**
* @coversDefaultClass \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
* @group jsonapi
*
* @internal
*/
class ResourceObjectNormalizerCacherTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'serialization',
'jsonapi',
'user',
];
/**
* The JSON:API resource type repository.
*
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
*/
protected $resourceTypeRepository;
/**
* The JSON:API serializer.
*
* @var \Drupal\jsonapi\Serializer\Serializer
*/
protected $serializer;
/**
* The object under test.
*
* @var \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
*/
protected $cacher;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
$this->serializer = $this->container->get('jsonapi.serializer');
$this->cacher = $this->container->get('jsonapi.normalization_cacher');
}
/**
* Tests that link normalization cache information is not lost.
*
* @see https://www.drupal.org/project/drupal/issues/3077287
*/
public function testLinkNormalizationCacheability() {
$user = User::create([
'name' => $this->randomMachineName(),
'pass' => $this->randomString(),
]);
$resource_type = $this->resourceTypeRepository->get($user->getEntityTypeId(), $user->bundle());
$resource_object = ResourceObject::createFromEntity($resource_type, $user);
$cache_tag_to_invalidate = 'link_normalization';
$normalized_links = $this->serializer
->normalize($resource_object->getLinks(), 'api_json')
->withCacheableDependency((new CacheableMetadata())->addCacheTags([$cache_tag_to_invalidate]));
assert($normalized_links instanceof CacheableNormalization);
$normalization_parts = [
ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE => [
'type' => CacheableNormalization::permanent($resource_object->getTypeName()),
'id' => CacheableNormalization::permanent($resource_object->getId()),
'links' => $normalized_links,
],
ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS => [],
];
$this->cacher->saveOnTerminate($resource_object, $normalization_parts);
$event = $this->prophesize(PostResponseEvent::class);
$this->cacher->onTerminate($event->reveal());
$this->assertNotFalse((bool) $this->cacher->get($resource_object));
Cache::invalidateTags([$cache_tag_to_invalidate]);
$this->assertFalse((bool) $this->cacher->get($resource_object));
}
}
@@ -0,0 +1,109 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\KernelTests\KernelTestBase;
/**
* Contains shared test utility methods.
*
* @internal
*/
abstract class JsonapiKernelTestBase extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['jsonapi'];
/**
* Creates a field of an entity reference field storage on the bundle.
*
* @param string $entity_type
* The type of entity the field will be attached to.
* @param string $bundle
* The bundle name of the entity the field will be attached to.
* @param string $field_name
* The name of the field; if it exists, a new instance of the existing.
* field will be created.
* @param string $field_label
* The label of the field.
* @param string $target_entity_type
* The type of the referenced entity.
* @param string $selection_handler
* The selection handler used by this field.
* @param array $handler_settings
* An array of settings supported by the selection handler specified above.
* (e.g. 'target_bundles', 'sort', 'auto_create', etc).
* @param int $cardinality
* The cardinality of the field.
*
* @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\SelectionBase::buildConfigurationForm()
*/
protected function createEntityReferenceField($entity_type, $bundle, $field_name, $field_label, $target_entity_type, $selection_handler = 'default', array $handler_settings = [], $cardinality = 1) {
// Look for or add the specified field to the requested entity bundle.
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'type' => 'entity_reference',
'entity_type' => $entity_type,
'cardinality' => $cardinality,
'settings' => [
'target_type' => $target_entity_type,
],
])->save();
}
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'bundle' => $bundle,
'label' => $field_label,
'settings' => [
'handler' => $selection_handler,
'handler_settings' => $handler_settings,
],
])->save();
}
}
/**
* Creates a field of an entity reference field storage on the bundle.
*
* @param string $entity_type
* The type of entity the field will be attached to.
* @param string $bundle
* The bundle name of the entity the field will be attached to.
* @param string $field_name
* The name of the field; if it exists, a new instance of the existing.
* field will be created.
* @param string $field_label
* The label of the field.
* @param int $cardinality
* The cardinality of the field.
*
* @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\SelectionBase::buildConfigurationForm()
*/
protected function createTextField($entity_type, $bundle, $field_name, $field_label, $cardinality = 1) {
// Look for or add the specified field to the requested entity bundle.
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'type' => 'text',
'entity_type' => $entity_type,
'cardinality' => $cardinality,
])->save();
}
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
FieldConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'bundle' => $bundle,
'label' => $field_label,
])->save();
}
}
}
@@ -0,0 +1,758 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Drupal\jsonapi\JsonApiResource\ErrorCollection;
use Drupal\jsonapi\JsonApiResource\LinkCollection;
use Drupal\jsonapi\JsonApiResource\NullIncludedData;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\Entity\User;
use Drupal\user\RoleInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* @coversDefaultClass \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
* @group jsonapi
*
* @internal
*/
class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
use ImageFieldCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'jsonapi',
'field',
'node',
'serialization',
'system',
'taxonomy',
'text',
'filter',
'user',
'file',
'image',
'jsonapi_test_normalizers_kernel',
];
/**
* A node to normalize.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $node;
/**
* A user to normalize.
*
* @var \Drupal\user\Entity\User
*/
protected $user;
/**
* The include resolver.
*
* @var \Drupal\jsonapi\IncludeResolver
*/
protected $includeResolver;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Add the entity schemas.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('taxonomy_term');
$this->installEntitySchema('file');
// Add the additional table schemas.
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
$this->installSchema('file', ['file_usage']);
$type = NodeType::create([
'type' => 'article',
]);
$type->save();
$this->createEntityReferenceField(
'node',
'article',
'field_tags',
'Tags',
'taxonomy_term',
'default',
['target_bundles' => ['tags']],
FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
);
$this->createTextField('node', 'article', 'body', 'Body');
$this->createImageField('field_image', 'article');
$this->user = User::create([
'name' => 'user1',
'mail' => 'user@localhost',
]);
$this->user2 = User::create([
'name' => 'user2',
'mail' => 'user2@localhost',
]);
$this->user->save();
$this->user2->save();
$this->vocabulary = Vocabulary::create(['name' => 'Tags', 'vid' => 'tags']);
$this->vocabulary->save();
$this->term1 = Term::create([
'name' => 'term1',
'vid' => $this->vocabulary->id(),
]);
$this->term2 = Term::create([
'name' => 'term2',
'vid' => $this->vocabulary->id(),
]);
$this->term1->save();
$this->term2->save();
$this->file = File::create([
'uri' => 'public://example.png',
'filename' => 'example.png',
]);
$this->file->save();
$this->node = Node::create([
'title' => 'dummy_title',
'type' => 'article',
'uid' => 1,
'body' => [
'format' => 'plain_text',
'value' => $this->randomStringValidate(42),
],
'field_tags' => [
['target_id' => $this->term1->id()],
['target_id' => $this->term2->id()],
],
'field_image' => [
[
'target_id' => $this->file->id(),
'alt' => 'test alt',
'title' => 'test title',
'width' => 10,
'height' => 11,
],
],
]);
$this->node->save();
$this->nodeType = NodeType::load('article');
Role::create([
'id' => RoleInterface::ANONYMOUS_ID,
'permissions' => [
'access content',
],
])->save();
$this->includeResolver = $this->container->get('jsonapi.include_resolver');
}
/**
* {@inheritdoc}
*/
public function tearDown() {
if ($this->node) {
$this->node->delete();
}
if ($this->term1) {
$this->term1->delete();
}
if ($this->term2) {
$this->term2->delete();
}
if ($this->vocabulary) {
$this->vocabulary->delete();
}
if ($this->user) {
$this->user->delete();
}
if ($this->user2) {
$this->user2->delete();
}
}
/**
* @covers ::normalize
*/
public function testNormalize() {
list($request, $resource_type) = $this->generateProphecies('node', 'article');
$resource_object = ResourceObject::createFromEntity($resource_type, $this->node);
$includes = $this->includeResolver->resolve($resource_object, 'uid,field_tags,field_image');
$jsonapi_doc_object = $this
->getNormalizer()
->normalize(
new JsonApiDocumentTopLevel(new ResourceObjectData([$resource_object], 1), $includes, new LinkCollection([])),
'api_json',
[
'resource_type' => $resource_type,
'account' => NULL,
'sparse_fieldset' => [
'node--article' => [
'title',
'node_type',
'uid',
'field_tags',
'field_image',
],
'user--user' => [
'display_name',
],
],
'include' => [
'uid',
'field_tags',
'field_image',
],
]
);
$normalized = $jsonapi_doc_object->getNormalization();
// @see http://jsonapi.org/format/#document-jsonapi-object
$this->assertEquals($normalized['jsonapi']['version'], '1.0');
$this->assertEquals($normalized['jsonapi']['meta']['links']['self']['href'], 'http://jsonapi.org/format/1.0/');
$this->assertSame($normalized['data']['attributes']['title'], 'dummy_title');
$this->assertEquals($normalized['data']['id'], $this->node->uuid());
$this->assertSame([
'data' => [
'type' => 'node_type--node_type',
'id' => NodeType::load('article')->uuid(),
],
'links' => [
'related' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/node_type', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
'self' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/relationships/node_type', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
],
], $normalized['data']['relationships']['node_type']);
$this->assertTrue(!isset($normalized['data']['attributes']['created']));
$this->assertEquals([
'alt' => 'test alt',
'title' => 'test title',
'width' => 10,
'height' => 11,
], $normalized['data']['relationships']['field_image']['data']['meta']);
$this->assertSame('node--article', $normalized['data']['type']);
$this->assertEquals([
'data' => [
'type' => 'user--user',
'id' => $this->user->uuid(),
],
'links' => [
'self' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/relationships/uid', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
'related' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/uid', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
],
], $normalized['data']['relationships']['uid']);
$this->assertTrue(empty($normalized['meta']['omitted']));
$this->assertSame($this->user->uuid(), $normalized['included'][0]['id']);
$this->assertSame('user--user', $normalized['included'][0]['type']);
$this->assertSame('user1', $normalized['included'][0]['attributes']['display_name']);
$this->assertCount(1, $normalized['included'][0]['attributes']);
$this->assertSame($this->term1->uuid(), $normalized['included'][1]['id']);
$this->assertSame('taxonomy_term--tags', $normalized['included'][1]['type']);
$this->assertSame($this->term1->label(), $normalized['included'][1]['attributes']['name']);
$this->assertCount(12, $normalized['included'][1]['attributes']);
$this->assertTrue(!isset($normalized['included'][1]['attributes']['created']));
// Make sure that the cache tags for the includes and the requested entities
// are bubbling as expected.
$this->assertArraySubset(
['file:1', 'node:1', 'taxonomy_term:1', 'taxonomy_term:2', 'user:1'],
$jsonapi_doc_object->getCacheTags()
);
$this->assertSame(
Cache::PERMANENT,
$jsonapi_doc_object->getCacheMaxAge()
);
}
/**
* @covers ::normalize
*/
public function testNormalizeRelated() {
$this->markTestIncomplete('This fails and should be fixed by https://www.drupal.org/project/jsonapi/issues/2922121');
list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uid');
$request->query = new ParameterBag([
'fields' => [
'user--user' => 'name,roles',
],
'include' => 'roles',
]);
$document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class);
$author = $this->node->get('uid')->entity;
$document_wrapper->getData()->willReturn($author);
$jsonapi_doc_object = $this
->getNormalizer()
->normalize(
$document_wrapper->reveal(),
'api_json',
[
'resource_type' => $resource_type,
'account' => NULL,
]
);
$normalized = $jsonapi_doc_object->getNormalization();
$this->assertSame($normalized['data']['attributes']['name'], 'user1');
$this->assertEquals($normalized['data']['id'], User::load(1)->uuid());
$this->assertEquals($normalized['data']['type'], 'user--user');
// Make sure that the cache tags for the includes and the requested entities
// are bubbling as expected.
$this->assertSame(['user:1'], $jsonapi_doc_object->getCacheTags());
$this->assertSame(Cache::PERMANENT, $jsonapi_doc_object->getCacheMaxAge());
}
/**
* @covers ::normalize
*/
public function testNormalizeUuid() {
list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uuid');
$resource_object = ResourceObject::createFromEntity($resource_type, $this->node);
$include_param = 'uid,field_tags';
$includes = $this->includeResolver->resolve($resource_object, $include_param);
$document_wrapper = new JsonApiDocumentTopLevel(new ResourceObjectData([$resource_object], 1), $includes, new LinkCollection([]));
$request->query = new ParameterBag([
'fields' => [
'node--article' => 'title,node_type,uid,field_tags',
'user--user' => 'name',
],
'include' => $include_param,
]);
$jsonapi_doc_object = $this
->getNormalizer()
->normalize(
$document_wrapper,
'api_json',
[
'resource_type' => $resource_type,
'account' => NULL,
'include' => [
'uid',
'field_tags',
],
]
);
$normalized = $jsonapi_doc_object->getNormalization();
$this->assertStringMatchesFormat($this->node->uuid(), $normalized['data']['id']);
$this->assertEquals($this->node->type->entity->uuid(), $normalized['data']['relationships']['node_type']['data']['id']);
$this->assertEquals($this->user->uuid(), $normalized['data']['relationships']['uid']['data']['id']);
$this->assertFalse(empty($normalized['included'][0]['id']));
$this->assertTrue(empty($normalized['meta']['omitted']));
$this->assertEquals($this->user->uuid(), $normalized['included'][0]['id']);
$this->assertCount(1, $normalized['included'][0]['attributes']);
$this->assertCount(12, $normalized['included'][1]['attributes']);
// Make sure that the cache tags for the includes and the requested entities
// are bubbling as expected.
$this->assertArraySubset(
['node:1', 'taxonomy_term:1', 'taxonomy_term:2', 'user:1'],
$jsonapi_doc_object->getCacheTags()
);
}
/**
* @covers ::normalize
*/
public function testNormalizeException() {
$normalized = $this
->container
->get('jsonapi.serializer')
->normalize(
new JsonApiDocumentTopLevel(new ErrorCollection([new BadRequestHttpException('Lorem')]), new NullIncludedData(), new LinkCollection([])),
'api_json',
[]
)->getNormalization();
$this->assertNotEmpty($normalized['errors']);
$this->assertArrayNotHasKey('data', $normalized);
$this->assertEquals(400, $normalized['errors'][0]['status']);
$this->assertEquals('Lorem', $normalized['errors'][0]['detail']);
$this->assertEquals([
'info' => [
'href' => 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1',
],
'via' => ['href' => 'http://localhost/'],
], $normalized['errors'][0]['links']);
}
/**
* @covers ::normalize
*/
public function testNormalizeConfig() {
list($request, $resource_type) = $this->generateProphecies('node_type', 'node_type', 'id');
$resource_object = ResourceObject::createFromEntity($resource_type, $this->nodeType);
$document_wrapper = new JsonApiDocumentTopLevel(new ResourceObjectData([$resource_object], 1), new NullIncludedData(), new LinkCollection([]));
$jsonapi_doc_object = $this
->getNormalizer()
->normalize($document_wrapper, 'api_json', [
'resource_type' => $resource_type,
'account' => NULL,
'sparse_fieldset' => [
'node_type--node_type' => [
'description',
'display_submitted',
],
],
]);
$normalized = $jsonapi_doc_object->getNormalization();
$this->assertSame(['description', 'display_submitted'], array_keys($normalized['data']['attributes']));
$this->assertSame($normalized['data']['id'], NodeType::load('article')->uuid());
$this->assertSame($normalized['data']['type'], 'node_type--node_type');
// Make sure that the cache tags for the includes and the requested entities
// are bubbling as expected.
$this->assertSame(['config:node.type.article'], $jsonapi_doc_object->getCacheTags());
}
/**
* Try to POST a node and check if it exists afterwards.
*
* @covers ::denormalize
*/
public function testDenormalize() {
$payload = '{"data":{"type":"article","attributes":{"title":"Testing article"}}}';
list($request, $resource_type) = $this->generateProphecies('node', 'article', 'id');
$node = $this
->getNormalizer()
->denormalize(Json::decode($payload), NULL, 'api_json', [
'resource_type' => $resource_type,
]);
$this->assertInstanceOf(Node::class, $node);
$this->assertSame('Testing article', $node->getTitle());
}
/**
* Try to POST a node and check if it exists afterwards.
*
* @covers ::denormalize
*/
public function testDenormalizeUuid() {
$configurations = [
// Good data.
[
[
[$this->term2->uuid(), $this->term1->uuid()],
$this->user2->uuid(),
],
[
[$this->term2->id(), $this->term1->id()],
$this->user2->id(),
],
],
// Good data, without any tags.
[
[
[],
$this->user2->uuid(),
],
[
[],
$this->user2->id(),
],
],
// Bad data in first tag.
[
[
['invalid-uuid', $this->term1->uuid()],
$this->user2->uuid(),
],
[
[$this->term1->id()],
$this->user2->id(),
],
'taxonomy_term--tags:invalid-uuid',
],
// Bad data in user and first tag.
[
[
['invalid-uuid', $this->term1->uuid()],
'also-invalid-uuid',
],
[
[$this->term1->id()],
NULL,
],
'user--user:also-invalid-uuid',
],
];
foreach ($configurations as $configuration) {
list($payload_data, $expected) = $this->denormalizeUuidProviderBuilder($configuration);
$payload = Json::encode($payload_data);
list($request, $resource_type) = $this->generateProphecies('node', 'article');
$this->container->get('request_stack')->push($request);
try {
$node = $this
->getNormalizer()
->denormalize(Json::decode($payload), NULL, 'api_json', [
'resource_type' => $resource_type,
]);
}
catch (NotFoundHttpException $e) {
$non_existing_resource_identifier = $configuration[2];
$this->assertEquals("The resource identified by `$non_existing_resource_identifier` (given as a relationship item) could not be found.", $e->getMessage());
continue;
}
/* @var \Drupal\node\Entity\Node $node */
$this->assertInstanceOf(Node::class, $node);
$this->assertSame('Testing article', $node->getTitle());
if (!empty($expected['user_id'])) {
$owner = $node->getOwner();
$this->assertEquals($expected['user_id'], $owner->id());
}
$tags = $node->get('field_tags')->getValue();
if (!empty($expected['tag_ids'][0])) {
$this->assertEquals($expected['tag_ids'][0], $tags[0]['target_id']);
}
else {
$this->assertArrayNotHasKey(0, $tags);
}
if (!empty($expected['tag_ids'][1])) {
$this->assertEquals($expected['tag_ids'][1], $tags[1]['target_id']);
}
else {
$this->assertArrayNotHasKey(1, $tags);
}
}
}
/**
* Tests denormalization for related resources with missing or invalid types.
*/
public function testDenormalizeInvalidTypeAndNoType() {
$payload_data = [
'data' => [
'type' => 'node--article',
'attributes' => [
'title' => 'Testing article',
'id' => '33095485-70D2-4E51-A309-535CC5BC0115',
],
'relationships' => [
'uid' => [
'data' => [
'type' => 'user--user',
'id' => $this->user2->uuid(),
],
],
'field_tags' => [
'data' => [
[
'type' => 'foobar',
'id' => $this->term1->uuid(),
],
],
],
],
],
];
// Test relationship member with invalid type.
$payload = Json::encode($payload_data);
list($request, $resource_type) = $this->generateProphecies('node', 'article');
$this->container->get('request_stack')->push($request);
try {
$this
->getNormalizer()
->denormalize(Json::decode($payload), NULL, 'api_json', [
'resource_type' => $resource_type,
]);
$this->fail('No assertion thrown for invalid type');
}
catch (BadRequestHttpException $e) {
$this->assertEquals("Invalid type specified for related resource: 'foobar'", $e->getMessage());
}
// Test relationship member with no type.
unset($payload_data['data']['relationships']['field_tags']['data'][0]['type']);
$payload = Json::encode($payload_data);
list($request, $resource_type) = $this->generateProphecies('node', 'article');
$this->container->get('request_stack')->push($request);
try {
$this->container->get('jsonapi_test_normalizers_kernel.jsonapi_document_toplevel')
->denormalize(Json::decode($payload), NULL, 'api_json', [
'resource_type' => $resource_type,
]);
$this->fail('No assertion thrown for missing type');
}
catch (BadRequestHttpException $e) {
$this->assertEquals("No type specified for related resource", $e->getMessage());
}
}
/**
* We cannot use a PHPUnit data provider because our data depends on $this.
*
* @param array $options
* Options for how to construct test data.
*
* @return array
* The test data.
*/
protected function denormalizeUuidProviderBuilder(array $options) {
list($input, $expected) = $options;
list($input_tag_uuids, $input_user_uuid) = $input;
list($expected_tag_ids, $expected_user_id) = $expected;
$node = [
[
'data' => [
'type' => 'node--article',
'attributes' => [
'title' => 'Testing article',
],
'relationships' => [
'uid' => [
'data' => [
'type' => 'user--user',
'id' => $input_user_uuid,
],
],
'field_tags' => [
'data' => [],
],
],
],
],
[
'tag_ids' => $expected_tag_ids,
'user_id' => $expected_user_id,
],
];
if (isset($input_tag_uuids[0])) {
$node[0]['data']['relationships']['field_tags']['data'][0] = [
'type' => 'taxonomy_term--tags',
'id' => $input_tag_uuids[0],
];
}
if (isset($input_tag_uuids[1])) {
$node[0]['data']['relationships']['field_tags']['data'][1] = [
'type' => 'taxonomy_term--tags',
'id' => $input_tag_uuids[1],
];
}
return $node;
}
/**
* Ensure that cacheability metadata is properly added.
*
* @param \Drupal\Core\Cache\CacheableMetadata $expected_metadata
* The expected cacheable metadata.
* @param array|null $fields
* Fields to include in the response, keyed by resource type.
* @param array|null $includes
* Resources paths to include in the response.
*
* @dataProvider testCacheableMetadataProvider
*/
public function testCacheableMetadata(CacheableMetadata $expected_metadata, $fields = NULL, $includes = NULL) {
list($request, $resource_type) = $this->generateProphecies('node', 'article');
$resource_object = ResourceObject::createFromEntity($resource_type, $this->node);
$context = [
'resource_type' => $resource_type,
'account' => NULL,
];
$jsonapi_doc_object = $this->getNormalizer()->normalize(new JsonApiDocumentTopLevel(new ResourceObjectData([$resource_object], 1), new NullIncludedData(), new LinkCollection([])), 'api_json', $context);
$this->assertArraySubset($expected_metadata->getCacheTags(), $jsonapi_doc_object->getCacheTags());
$this->assertArraySubset($expected_metadata->getCacheContexts(), $jsonapi_doc_object->getCacheContexts());
$this->assertSame($expected_metadata->getCacheMaxAge(), $jsonapi_doc_object->getCacheMaxAge());
}
/**
* Provides test cases for asserting cacheable metadata behavior.
*/
public function testCacheableMetadataProvider() {
$cacheable_metadata = function ($metadata) {
return CacheableMetadata::createFromRenderArray(['#cache' => $metadata]);
};
return [
[
$cacheable_metadata(['contexts' => ['languages:language_interface']]),
['node--article' => 'body'],
],
];
}
/**
* Decorates a request with sparse fieldsets and includes.
*/
protected function decorateRequest(Request $request, array $fields = NULL, array $includes = NULL) {
$parameters = new ParameterBag();
$parameters->add($fields ? ['fields' => $fields] : []);
$parameters->add($includes ? ['include' => $includes] : []);
$request->query = $parameters;
return $request;
}
/**
* Helper to load the normalizer.
*/
protected function getNormalizer() {
$normalizer_service = $this->container->get('jsonapi_test_normalizers_kernel.jsonapi_document_toplevel');
// Simulate what happens when this normalizer service is used via the
// serializer service, as it is meant to be used.
$normalizer_service->setSerializer($this->container->get('jsonapi.serializer'));
return $normalizer_service;
}
/**
* Generates the prophecies for the mocked entity request.
*
* @param string $entity_type_id
* The ID of the entity type. Ex: node.
* @param string $bundle
* The bundle. Ex: article.
*
* @return array
* A numeric array containing the request and the ResourceType.
*
* @throws \Exception
*/
protected function generateProphecies($entity_type_id, $bundle) {
$resource_type = $this->container->get('jsonapi.resource_type.repository')->get($entity_type_id, $bundle);
return [new Request(), $resource_type];
}
}
@@ -0,0 +1,326 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\jsonapi\JsonApiResource\Relationship;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\Normalizer\RelationshipNormalizer;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
use Drupal\user\Entity\User;
/**
* @coversDefaultClass \Drupal\jsonapi\Normalizer\RelationshipNormalizer
* @group jsonapi
*
* @internal
*/
class RelationshipNormalizerTest extends JsonapiKernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'field',
'file',
'image',
'jsonapi',
'node',
'serialization',
'system',
'user',
];
/**
* Static UUID for the referencing entity.
*
* @var string
*/
protected static $referencerId = '2c344ae5-4303-4f17-acd4-e20d2a9a6c44';
/**
* Static UUIDs for use in tests.
*
* @var string[]
*/
protected static $userIds = [
'457fed75-a3ed-4e9e-823c-f9aeff6ec8ca',
'67e4063f-ac74-46ac-ac5f-07efda9fd551',
];
/**
* Static UUIDs for use in tests.
*
* @var string[]
*/
protected static $imageIds = [
'71e67249-df4a-4616-9065-4cc2e812235b',
'ce5093fc-417f-477d-932d-888407d5cbd5',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Set up the data model.
// Add the entity schemas.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('file');
// Add the additional table schemas.
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('file', ['file_usage']);
NodeType::create([
'type' => 'referencer',
])->save();
$this->createEntityReferenceField('node', 'referencer', 'field_user', 'User', 'user', 'default', ['target_bundles' => NULL], 1);
$this->createEntityReferenceField('node', 'referencer', 'field_users', 'Users', 'user', 'default', ['target_bundles' => NULL], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
$field_storage_config = [
'type' => 'image',
'entity_type' => 'node',
];
FieldStorageConfig::create(['field_name' => 'field_image', 'cardinality' => 1] + $field_storage_config)->save();
FieldStorageConfig::create(['field_name' => 'field_images', 'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED] + $field_storage_config)->save();
$field_config = [
'entity_type' => 'node',
'bundle' => 'referencer',
];
FieldConfig::create(['field_name' => 'field_image', 'label' => 'Image'] + $field_config)->save();
FieldConfig::create(['field_name' => 'field_images', 'label' => 'Images'] + $field_config)->save();
// Set up the test data.
$this->account = $this->prophesize(AccountInterface::class)->reveal();
$this->user1 = User::create([
'name' => $this->randomMachineName(),
'mail' => $this->randomMachineName() . '@example.com',
'uuid' => static::$userIds[0],
]);
$this->user1->save();
$this->user2 = User::create([
'name' => $this->randomMachineName(),
'mail' => $this->randomMachineName() . '@example.com',
'uuid' => static::$userIds[1],
]);
$this->user2->save();
$this->image1 = File::create([
'uri' => 'public:/image1.png',
'uuid' => static::$imageIds[0],
]);
$this->image1->save();
$this->image2 = File::create([
'uri' => 'public:/image2.png',
'uuid' => static::$imageIds[1],
]);
$this->image2->save();
// Create the node from which all the previously created entities will be
// referenced.
$this->referencer = Node::create([
'title' => 'Referencing node',
'type' => 'referencer',
'status' => 1,
'uuid' => static::$referencerId,
]);
$this->referencer->save();
// Set up the test dependencies.
$this->referencingResourceType = $this->container->get('jsonapi.resource_type.repository')->get('node', 'referencer');
$this->normalizer = new RelationshipNormalizer();
$this->normalizer->setSerializer($this->container->get('jsonapi.serializer'));
}
/**
* @covers ::normalize
* @dataProvider normalizeProvider
*/
public function testNormalize($entity_property_names, $field_name, $expected) {
// Links cannot be generated in the test provider because the container
// has not yet been set.
$expected['links'] = [
'self' => ['href' => Url::fromUri('base:/jsonapi/node/referencer/' . static::$referencerId . "/relationships/$field_name", ['query' => ['resourceVersion' => 'id:1']])->setAbsolute()->toString()],
'related' => ['href' => Url::fromUri('base:/jsonapi/node/referencer/' . static::$referencerId . "/$field_name", ['query' => ['resourceVersion' => 'id:1']])->setAbsolute()->toString()],
];
// Set up different field values.
$this->referencer->{$field_name} = array_map(function ($entity_property_name) {
$value = ['target_id' => $this->{$entity_property_name === 'image1a' ? 'image1' : $entity_property_name}->id()];
switch ($entity_property_name) {
case 'image1':
$value['alt'] = 'Cute llama';
$value['title'] = 'My spirit animal';
break;
case 'image1a':
$value['alt'] = 'Ugly llama';
$value['title'] = 'My alter ego';
break;
case 'image2':
$value['alt'] = 'Adorable llama';
$value['title'] = 'My spirit animal 😍';
break;
}
return $value;
}, $entity_property_names);
$resource_object = ResourceObject::createFromEntity($this->referencingResourceType, $this->referencer);
$relationship = Relationship::createFromEntityReferenceField($resource_object, $resource_object->getField($field_name));
// Normalize.
$actual = $this->normalizer->normalize($relationship, 'api_json');
// Assert.
assert($actual instanceof CacheableNormalization);
$this->assertEquals($expected, $actual->getNormalization());
}
/**
* Data provider for testNormalize.
*/
public function normalizeProvider() {
return [
'single cardinality' => [
['user1'],
'field_user',
[
'data' => ['type' => 'user--user', 'id' => static::$userIds[0]],
],
],
'multiple cardinality' => [
['user1', 'user2'], 'field_users', [
'data' => [
['type' => 'user--user', 'id' => static::$userIds[0]],
['type' => 'user--user', 'id' => static::$userIds[1]],
],
],
],
'multiple cardinality, all same values' => [
['user1', 'user1'], 'field_users', [
'data' => [
[
'type' => 'user--user',
'id' => static::$userIds[0],
'meta' => ['arity' => 0],
],
[
'type' => 'user--user',
'id' => static::$userIds[0],
'meta' => ['arity' => 1],
],
],
],
],
'multiple cardinality, some same values' => [
['user1', 'user2', 'user1'], 'field_users', [
'data' => [
[
'type' => 'user--user',
'id' => static::$userIds[0],
'meta' => ['arity' => 0],
],
[
'type' => 'user--user',
'id' => static::$userIds[1],
],
[
'type' => 'user--user',
'id' => static::$userIds[0],
'meta' => ['arity' => 1],
],
],
],
],
'single cardinality, with meta' => [
['image1'], 'field_image', [
'data' => [
'type' => 'file--file',
'id' => static::$imageIds[0],
'meta' => [
'alt' => 'Cute llama',
'title' => 'My spirit animal',
'width' => NULL,
'height' => NULL,
],
],
],
],
'multiple cardinality, all same values, with meta' => [
['image1', 'image1'], 'field_images', [
'data' => [
[
'type' => 'file--file',
'id' => static::$imageIds[0],
'meta' => [
'alt' => 'Cute llama',
'title' => 'My spirit animal',
'width' => NULL,
'height' => NULL,
'arity' => 0,
],
],
[
'type' => 'file--file',
'id' => static::$imageIds[0],
'meta' => [
'alt' => 'Cute llama',
'title' => 'My spirit animal',
'width' => NULL,
'height' => NULL,
'arity' => 1,
],
],
],
],
],
'multiple cardinality, some same values with same values but different meta' => [
['image1', 'image1', 'image1a'], 'field_images', [
'data' => [
[
'type' => 'file--file',
'id' => static::$imageIds[0],
'meta' => [
'alt' => 'Cute llama',
'title' => 'My spirit animal',
'width' => NULL,
'height' => NULL,
'arity' => 0,
],
],
[
'type' => 'file--file',
'id' => static::$imageIds[0],
'meta' => [
'alt' => 'Cute llama',
'title' => 'My spirit animal',
'width' => NULL,
'height' => NULL,
'arity' => 1,
],
],
[
'type' => 'file--file',
'id' => static::$imageIds[0],
'meta' => [
'alt' => 'Ugly llama',
'title' => 'My alter ego',
'width' => NULL,
'height' => NULL,
'arity' => 2,
],
],
],
],
],
];
}
}
@@ -0,0 +1,420 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\Query;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
use Drupal\jsonapi\Context\FieldResolver;
use Drupal\jsonapi\Query\Filter;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
use Prophecy\Argument;
/**
* @coversDefaultClass \Drupal\jsonapi\Query\Filter
* @group jsonapi
* @group jsonapi_query
*
* @internal
*/
class FilterTest extends JsonapiKernelTestBase {
use ImageFieldCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'field',
'file',
'image',
'jsonapi',
'node',
'serialization',
'system',
'text',
'user',
];
/**
* A node storage instance.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $nodeStorage;
/**
* The JSON:API resource type repository.
*
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
*/
protected $resourceTypeRepository;
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$this->setUpSchemas();
$this->savePaintingType();
// ((RED or CIRCLE) or (YELLOW and SQUARE))
$this->savePaintings([
['colors' => ['red'], 'shapes' => ['triangle'], 'title' => 'FIND'],
['colors' => ['orange'], 'shapes' => ['circle'], 'title' => 'FIND'],
['colors' => ['orange'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'],
['colors' => ['yellow'], 'shapes' => ['square'], 'title' => 'FIND'],
['colors' => ['yellow'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'],
['colors' => ['orange'], 'shapes' => ['square'], 'title' => 'DONT_FIND'],
]);
$this->nodeStorage = $this->container->get('entity_type.manager')->getStorage('node');
$this->fieldResolver = $this->container->get('jsonapi.field_resolver');
$this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
}
/**
* @covers ::queryCondition
*/
public function testInvalidFilterPathDueToMissingPropertyName() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('Invalid nested filtering. The field `colors`, given in the path `colors` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.');
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
Filter::createFromQueryParameter(['colors' => ''], $resource_type, $this->fieldResolver);
}
/**
* @covers ::queryCondition
*/
public function testInvalidFilterPathDueToMissingPropertyNameReferenceFieldWithMetaProperties() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('Invalid nested filtering. The field `photo`, given in the path `photo` is incomplete, it must end with one of the following specifiers: `id`, `meta.alt`, `meta.title`, `meta.width`, `meta.height`.');
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
Filter::createFromQueryParameter(['photo' => ''], $resource_type, $this->fieldResolver);
}
/**
* @covers ::queryCondition
*/
public function testInvalidFilterPathDueMissingMetaPrefixReferenceFieldWithMetaProperties() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('Invalid nested filtering. The property `alt`, given in the path `photo.alt` belongs to the meta object of a relationship and must be preceded by `meta`.');
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
Filter::createFromQueryParameter(['photo.alt' => ''], $resource_type, $this->fieldResolver);
}
/**
* @covers ::queryCondition
*/
public function testInvalidFilterPathDueToMissingPropertyNameReferenceFieldWithoutMetaProperties() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('Invalid nested filtering. The field `uid`, given in the path `uid` is incomplete, it must end with one of the following specifiers: `id`.');
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
Filter::createFromQueryParameter(['uid' => ''], $resource_type, $this->fieldResolver);
}
/**
* @covers ::queryCondition
*/
public function testInvalidFilterPathDueToNonexistentProperty() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('Invalid nested filtering. The property `foobar`, given in the path `colors.foobar`, does not exist. Must be one of the following property names: `value`, `format`, `processed`.');
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
Filter::createFromQueryParameter(['colors.foobar' => ''], $resource_type, $this->fieldResolver);
}
/**
* @covers ::queryCondition
*/
public function testInvalidFilterPathDueToElidedSoleProperty() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('Invalid nested filtering. The property `value`, given in the path `promote.value`, does not exist. Filter by `promote`, not `promote.value` (the JSON:API module elides property names from single-property fields).');
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
Filter::createFromQueryParameter(['promote.value' => ''], $resource_type, $this->fieldResolver);
}
/**
* @covers ::queryCondition
*/
public function testQueryCondition() {
// Can't use a data provider because we need access to the container.
$data = $this->queryConditionData();
$get_sql_query_for_entity_query = function ($entity_query) {
// Expose parts of \Drupal\Core\Entity\Query\Sql\Query::execute().
$o = new \ReflectionObject($entity_query);
$m1 = $o->getMethod('prepare');
$m1->setAccessible(TRUE);
$m2 = $o->getMethod('compile');
$m2->setAccessible(TRUE);
// The private property computed by the two previous private calls, whose
// value we need to inspect.
$p = $o->getProperty('sqlQuery');
$p->setAccessible(TRUE);
$m1->invoke($entity_query);
$m2->invoke($entity_query);
return (string) $p->getValue($entity_query);
};
$resource_type = $this->resourceTypeRepository->get('node', 'painting');
foreach ($data as $case) {
$parameter = $case[0];
$expected_query = $case[1];
$filter = Filter::createFromQueryParameter($parameter, $resource_type, $this->fieldResolver);
$query = $this->nodeStorage->getQuery();
// Get the query condition parsed from the input.
$condition = $filter->queryCondition($query);
// Apply it to the query.
$query->condition($condition);
// Verify the SQL query is exactly the same.
$expected_sql_query = $get_sql_query_for_entity_query($expected_query);
$actual_sql_query = $get_sql_query_for_entity_query($query);
$this->assertSame($expected_sql_query, $actual_sql_query);
// Compare the results.
$this->assertEquals($expected_query->execute(), $query->execute());
}
}
/**
* Simply provides test data to keep the actual test method tidy.
*/
protected function queryConditionData() {
// ((RED or CIRCLE) or (YELLOW and SQUARE))
$query = $this->nodeStorage->getQuery();
$or_group = $query->orConditionGroup();
$nested_or_group = $query->orConditionGroup();
$nested_or_group->condition('colors', 'red', 'CONTAINS');
$nested_or_group->condition('shapes', 'circle', 'CONTAINS');
$or_group->condition($nested_or_group);
$nested_and_group = $query->andConditionGroup();
$nested_and_group->condition('colors', 'yellow', 'CONTAINS');
$nested_and_group->condition('shapes', 'square', 'CONTAINS');
$nested_and_group->notExists('photo.alt');
$or_group->condition($nested_and_group);
$query->condition($or_group);
return [
[
[
'or-group' => ['group' => ['conjunction' => 'OR']],
'nested-or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'or-group']],
'nested-and-group' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'or-group']],
'condition-0' => [
'condition' => [
'path' => 'colors.value',
'value' => 'red',
'operator' => 'CONTAINS',
'memberOf' => 'nested-or-group',
],
],
'condition-1' => [
'condition' => [
'path' => 'shapes.value',
'value' => 'circle',
'operator' => 'CONTAINS',
'memberOf' => 'nested-or-group',
],
],
'condition-2' => [
'condition' => [
'path' => 'colors.value',
'value' => 'yellow',
'operator' =>
'CONTAINS',
'memberOf' => 'nested-and-group',
],
],
'condition-3' => [
'condition' => [
'path' => 'shapes.value',
'value' => 'square',
'operator' => 'CONTAINS',
'memberOf' => 'nested-and-group',
],
],
'condition-4' => [
'condition' => [
'path' => 'photo.meta.alt',
'operator' => 'IS NULL',
'memberOf' => 'nested-and-group',
],
],
],
$query,
],
];
}
/**
* Sets up the schemas.
*/
protected function setUpSchemas() {
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
$this->installSchema('user', []);
foreach (['user', 'node'] as $entity_type_id) {
$this->installEntitySchema($entity_type_id);
}
}
/**
* Creates a painting node type.
*/
protected function savePaintingType() {
NodeType::create([
'type' => 'painting',
])->save();
$this->createTextField(
'node', 'painting',
'colors', 'Colors',
FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
);
$this->createTextField(
'node', 'painting',
'shapes', 'Shapes',
FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
);
$this->createImageField('photo', 'painting');
}
/**
* Creates painting nodes.
*/
protected function savePaintings($paintings) {
foreach ($paintings as $painting) {
Node::create(array_merge([
'type' => 'painting',
], $painting))->save();
}
}
/**
* @covers ::createFromQueryParameter
* @dataProvider parameterProvider
*/
public function testCreateFromQueryParameter($case, $expected) {
$resource_type = new ResourceType('foo', 'bar', NULL);
$actual = Filter::createFromQueryParameter($case, $resource_type, $this->getFieldResolverMock($resource_type));
$conditions = $actual->root()->members();
for ($i = 0; $i < count($case); $i++) {
$this->assertEquals($expected[$i]['path'], $conditions[$i]->field());
$this->assertEquals($expected[$i]['value'], $conditions[$i]->value());
$this->assertEquals($expected[$i]['operator'], $conditions[$i]->operator());
}
}
/**
* Data provider for testCreateFromQueryParameter.
*/
public function parameterProvider() {
return [
'shorthand' => [
['uid' => ['value' => 1]],
[['path' => 'uid', 'value' => 1, 'operator' => '=']],
],
'extreme shorthand' => [
['uid' => 1],
[['path' => 'uid', 'value' => 1, 'operator' => '=']],
],
];
}
/**
* @covers ::createFromQueryParameter
*/
public function testCreateFromQueryParameterNested() {
$parameter = [
'or-group' => ['group' => ['conjunction' => 'OR']],
'nested-or-group' => [
'group' => ['conjunction' => 'OR', 'memberOf' => 'or-group'],
],
'nested-and-group' => [
'group' => ['conjunction' => 'AND', 'memberOf' => 'or-group'],
],
'condition-0' => [
'condition' => [
'path' => 'field0',
'value' => 'value0',
'memberOf' => 'nested-or-group',
],
],
'condition-1' => [
'condition' => [
'path' => 'field1',
'value' => 'value1',
'memberOf' => 'nested-or-group',
],
],
'condition-2' => [
'condition' => [
'path' => 'field2',
'value' => 'value2',
'memberOf' => 'nested-and-group',
],
],
'condition-3' => [
'condition' => [
'path' => 'field3',
'value' => 'value3',
'memberOf' => 'nested-and-group',
],
],
];
$resource_type = new ResourceType('foo', 'bar', NULL);
$filter = Filter::createFromQueryParameter($parameter, $resource_type, $this->getFieldResolverMock($resource_type));
$root = $filter->root();
// Make sure the implicit root group was added.
$this->assertEquals($root->conjunction(), 'AND');
// Ensure the or-group and the and-group were added correctly.
$members = $root->members();
// Ensure the OR group was added.
$or_group = $members[0];
$this->assertEquals($or_group->conjunction(), 'OR');
$or_group_members = $or_group->members();
// Make sure the nested OR group was added with the right conditions.
$nested_or_group = $or_group_members[0];
$this->assertEquals($nested_or_group->conjunction(), 'OR');
$nested_or_group_members = $nested_or_group->members();
$this->assertEquals($nested_or_group_members[0]->field(), 'field0');
$this->assertEquals($nested_or_group_members[1]->field(), 'field1');
// Make sure the nested AND group was added with the right conditions.
$nested_and_group = $or_group_members[1];
$this->assertEquals($nested_and_group->conjunction(), 'AND');
$nested_and_group_members = $nested_and_group->members();
$this->assertEquals($nested_and_group_members[0]->field(), 'field2');
$this->assertEquals($nested_and_group_members[1]->field(), 'field3');
}
/**
* Provides a mock field resolver.
*/
protected function getFieldResolverMock(ResourceType $resource_type) {
$field_resolver = $this->prophesize(FieldResolver::class);
$field_resolver->resolveInternalEntityQueryPath($resource_type, Argument::any())->willReturnArgument(1);
return $field_resolver->reveal();
}
}
@@ -0,0 +1,182 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\ResourceType;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
use Drupal\node\Entity\NodeType;
/**
* @coversDefaultClass \Drupal\jsonapi\ResourceType\ResourceType
* @coversClass \Drupal\jsonapi\ResourceType\ResourceTypeRepository
* @group jsonapi
*
* @internal
*/
class RelatedResourceTypesTest extends JsonapiKernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'jsonapi',
'serialization',
'system',
'user',
'field',
];
/**
* The JSON:API resource type repository under test.
*
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
*/
protected $resourceTypeRepository;
/**
* The JSON:API resource type for `node--foo`.
*
* @var \Drupal\jsonapi\ResourceType\ResourceType
*/
protected $fooType;
/**
* The JSON:API resource type for `node--bar`.
*
* @var \Drupal\jsonapi\ResourceType\ResourceType
*/
protected $barType;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Add the entity schemas.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
// Add the additional table schemas.
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
NodeType::create([
'type' => 'foo',
])->save();
NodeType::create([
'type' => 'bar',
])->save();
$this->createEntityReferenceField(
'node',
'foo',
'field_ref_bar',
'Bar Reference',
'node',
'default',
['target_bundles' => ['bar']]
);
$this->createEntityReferenceField(
'node',
'foo',
'field_ref_foo',
'Foo Reference',
'node',
'default',
// Important to test self-referencing resource types.
['target_bundles' => ['foo']]
);
$this->createEntityReferenceField(
'node',
'foo',
'field_ref_any',
'Any Bundle Reference',
'node',
'default',
// This should result in a reference to any bundle.
['target_bundles' => NULL]
);
$this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
}
/**
* @covers ::getRelatableResourceTypes
* @dataProvider getRelatableResourceTypesProvider
*/
public function testGetRelatableResourceTypes($resource_type_name, $relatable_type_names) {
// We're only testing the fields that we set up.
$test_fields = [
'field_ref_foo',
'field_ref_bar',
'field_ref_any',
];
$resource_type = $this->resourceTypeRepository->getByTypeName($resource_type_name);
// This extracts just the relationship fields under test.
$subjects = array_intersect_key(
$resource_type->getRelatableResourceTypes(),
array_flip($test_fields)
);
// Map the related resource type to their type name so we can just compare
// the type names rather that the whole object.
foreach ($test_fields as $field_name) {
if (isset($subjects[$field_name])) {
$subjects[$field_name] = array_map(function ($resource_type) {
return $resource_type->getTypeName();
}, $subjects[$field_name]);
}
}
$this->assertArraySubset($relatable_type_names, $subjects);
}
/**
* @covers ::getRelatableResourceTypes
* @dataProvider getRelatableResourceTypesProvider
*/
public function getRelatableResourceTypesProvider() {
return [
[
'node--foo',
[
'field_ref_foo' => ['node--foo'],
'field_ref_bar' => ['node--bar'],
'field_ref_any' => ['node--foo', 'node--bar'],
],
],
['node--bar', []],
];
}
/**
* @covers ::getRelatableResourceTypesByField
* @dataProvider getRelatableResourceTypesByFieldProvider
*/
public function testGetRelatableResourceTypesByField($entity_type_id, $bundle, $field) {
$resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
$relatable_types = $resource_type->getRelatableResourceTypes();
$this->assertSame(
$relatable_types[$field],
$resource_type->getRelatableResourceTypesByField($field)
);
}
/**
* Provides cases to test getRelatableTypesByField.
*/
public function getRelatableResourceTypesByFieldProvider() {
return [
['node', 'foo', 'field_ref_foo'],
['node', 'foo', 'field_ref_bar'],
['node', 'foo', 'field_ref_any'],
];
}
}
@@ -0,0 +1,210 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\ResourceType;
use Drupal\Core\Cache\Cache;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
/**
* @coversDefaultClass \Drupal\jsonapi\ResourceType\ResourceTypeRepository
* @group jsonapi
*
* @internal
*/
class ResourceTypeRepositoryTest extends JsonapiKernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'field',
'node',
'serialization',
'system',
'user',
'jsonapi_test_resource_type_building',
];
/**
* The JSON:API resource type repository under test.
*
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
*/
protected $resourceTypeRepository;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Add the entity schemas.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
// Add the additional table schemas.
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
NodeType::create([
'type' => 'article',
])->save();
NodeType::create([
'type' => 'page',
])->save();
NodeType::create([
'type' => '42',
])->save();
$this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
}
/**
* @covers ::all
*/
public function testAll() {
// Make sure that there are resources being created.
$all = $this->resourceTypeRepository->all();
$this->assertNotEmpty($all);
array_walk($all, function (ResourceType $resource_type) {
$this->assertNotEmpty($resource_type->getDeserializationTargetClass());
$this->assertNotEmpty($resource_type->getEntityTypeId());
$this->assertNotEmpty($resource_type->getTypeName());
});
}
/**
* @covers ::get
* @dataProvider getProvider
*/
public function testGet($entity_type_id, $bundle, $entity_class) {
// Make sure that there are resources being created.
$resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
$this->assertInstanceOf(ResourceType::class, $resource_type);
$this->assertSame($entity_class, $resource_type->getDeserializationTargetClass());
$this->assertSame($entity_type_id, $resource_type->getEntityTypeId());
$this->assertSame($bundle, $resource_type->getBundle());
$this->assertSame($entity_type_id . '--' . $bundle, $resource_type->getTypeName());
}
/**
* Data provider for testGet.
*
* @returns array
* The data for the test method.
*/
public function getProvider() {
return [
['node', 'article', 'Drupal\node\Entity\Node'],
['node', '42', 'Drupal\node\Entity\Node'],
['node_type', 'node_type', 'Drupal\node\Entity\NodeType'],
['menu', 'menu', 'Drupal\system\Entity\Menu'],
];
}
/**
* Ensures that the ResourceTypeRepository's cache does not become stale.
*/
public function testCaching() {
$this->assertEmpty($this->resourceTypeRepository->get('node', 'article')->getRelatableResourceTypesByField('field_relationship'));
$this->createEntityReferenceField('node', 'article', 'field_relationship', 'Related entity', 'node');
$this->assertCount(3, $this->resourceTypeRepository->get('node', 'article')->getRelatableResourceTypesByField('field_relationship'));
NodeType::create(['type' => 'camelids'])->save();
$this->assertCount(4, $this->resourceTypeRepository->get('node', 'article')->getRelatableResourceTypesByField('field_relationship'));
}
/**
* Ensures that a naming conflict in the mapping causes an exception to be
* thrown.
*
* @covers ::getFieldMapping
* @dataProvider getFieldMappingProvider
*/
public function testMappingNameConflictCheck($field_name_list) {
$entity_type = \Drupal::entityTypeManager()->getDefinition('node');
$bundle = 'article';
$reflection_class = new \ReflectionClass($this->resourceTypeRepository);
$reflection_method = $reflection_class->getMethod('getFields');
$reflection_method->setAccessible(TRUE);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage("The generated alias '{$field_name_list[1]}' for field name '{$field_name_list[0]}' conflicts with an existing field. Please report this in the JSON:API issue queue!");
$reflection_method->invokeArgs($this->resourceTypeRepository, [$field_name_list, $entity_type, $bundle]);
}
/**
* Data provider for testGetFieldMapping.
*
* These field name lists are designed to trigger a naming conflict in the
* mapping: the special-cased names "type" or "id", and the name
* "{$entity_type_id}_type" or "{$entity_type_id}_id", respectively.
*
* @returns array
* The data for the test method.
*/
public function getFieldMappingProvider() {
return [
[['type', 'node_type']],
[['id', 'node_id']],
];
}
/**
* Tests that resource types can be disabled by a build subscriber.
*/
public function testResourceTypeDisabling() {
$this->assertFalse($this->resourceTypeRepository->getByTypeName('node--article')->isInternal());
$this->assertFalse($this->resourceTypeRepository->getByTypeName('node--page')->isInternal());
$this->assertFalse($this->resourceTypeRepository->getByTypeName('user--user')->isInternal());
$disabled_resource_types = [
'node--page',
'user--user',
];
\Drupal::state()->set('jsonapi_test_resource_type_builder.disabled_resource_types', $disabled_resource_types);
Cache::invalidateTags(['jsonapi_resource_types']);
$this->assertFalse($this->resourceTypeRepository->getByTypeName('node--article')->isInternal());
$this->assertTrue($this->resourceTypeRepository->getByTypeName('node--page')->isInternal());
$this->assertTrue($this->resourceTypeRepository->getByTypeName('user--user')->isInternal());
}
/**
* Tests that resource type fields can be aliased per resource type.
*/
public function testResourceTypeFieldAliasing() {
$this->assertSame($this->resourceTypeRepository->getByTypeName('node--article')->getPublicName('uid'), 'uid');
$this->assertSame($this->resourceTypeRepository->getByTypeName('node--page')->getPublicName('uid'), 'uid');
$resource_type_field_aliases = [
'node--article' => [
'uid' => 'author',
],
'node--page' => [
'uid' => 'owner',
],
];
\Drupal::state()->set('jsonapi_test_resource_type_builder.resource_type_field_aliases', $resource_type_field_aliases);
Cache::invalidateTags(['jsonapi_resource_types']);
$this->assertSame($this->resourceTypeRepository->getByTypeName('node--article')->getPublicName('uid'), 'author');
$this->assertSame($this->resourceTypeRepository->getByTypeName('node--page')->getPublicName('uid'), 'owner');
}
/**
* Tests that resource type fields can be disabled per resource type.
*/
public function testResourceTypeFieldDisabling() {
$this->assertTrue($this->resourceTypeRepository->getByTypeName('node--article')->isFieldEnabled('uid'));
$this->assertTrue($this->resourceTypeRepository->getByTypeName('node--page')->isFieldEnabled('uid'));
$disabled_resource_type_fields = [
'node--article' => [
'uid' => TRUE,
],
'node--page' => [
'uid' => FALSE,
],
];
\Drupal::state()->set('jsonapi_test_resource_type_builder.disabled_resource_type_fields', $disabled_resource_type_fields);
Cache::invalidateTags(['jsonapi_resource_types']);
$this->assertFalse($this->resourceTypeRepository->getByTypeName('node--article')->isFieldEnabled('uid'));
$this->assertTrue($this->resourceTypeRepository->getByTypeName('node--page')->isFieldEnabled('uid'));
}
}
@@ -0,0 +1,46 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\ResourceType;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
/**
* @coversDefaultClass \Drupal\jsonapi\ResourceType\ResourceType
* @group jsonapi
*
* @internal
*/
class ResourceTypeTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'serialization',
'jsonapi',
'user',
'text',
'node',
];
/**
* Tests construction of a ResourceType using a deprecated $fields argument.
*
* @group legacy
* @expectedDeprecation Passing an array with strings or booleans as a field mapping to Drupal\jsonapi\ResourceType\ResourceType::__construct() is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. See \Drupal\jsonapi\ResourceTypeRepository::getFields(). See https://www.drupal.org/node/3084746.
* @covers ::__construct
* @covers ::updateDeprecatedFieldMapping
*/
public function testUpdateDeprecatedFieldMapping() {
$deprecated_field_mapping = [
'uid' => 'author',
'body' => FALSE,
];
$resource_type = new ResourceType('node', 'article', Node::class, FALSE, TRUE, TRUE, FALSE, $deprecated_field_mapping);
$this->assertSame('author', $resource_type->getFieldByInternalName('uid')->getPublicName());
$this->assertFalse($resource_type->getFieldByInternalName('body')->isFieldEnabled());
}
}
@@ -0,0 +1,168 @@
<?php
namespace Drupal\Tests\jsonapi\Kernel\Revisions;
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
use Drupal\Core\Http\Exception\CacheableNotFoundHttpException;
use Drupal\jsonapi\Revisions\VersionById;
use Drupal\jsonapi\Revisions\VersionByRel;
use Drupal\jsonapi\Revisions\VersionNegotiator;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
use Drupal\user\Entity\User;
/**
* The test class for version negotiators.
*
* @coversDefaultClass \Drupal\jsonapi\Revisions\VersionNegotiator
* @group jsonapi
*
* @internal
*/
class VersionNegotiatorTest extends JsonapiKernelTestBase {
/**
* The user.
*
* @var \Drupal\user\Entity\User
*/
protected $user;
/**
* The node.
*
* @var \Drupal\node\Entity\Node
*/
protected $node;
/**
* The previous revision ID of $node.
*
* @var string
*/
protected $nodePreviousRevisionId;
/**
* The version negotiator service.
*
* @var \Drupal\jsonapi\Revisions\VersionNegotiator
*/
protected $versionNegotiator;
/**
* The other node.
*
* @var \Drupal\node\Entity\Node
*/
protected $node2;
public static $modules = [
'node',
'field',
'jsonapi',
'serialization',
'system',
'user',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Add the entity schemas.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
// Add the additional table schemas.
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
$type = NodeType::create([
'type' => 'dummy',
'new_revision' => TRUE,
]);
$type->save();
$this->user = User::create([
'name' => 'user1',
'mail' => 'user@localhost',
'status' => 1,
]);
$this->user->save();
$this->node = Node::create([
'title' => 'dummy_title',
'type' => 'dummy',
'uid' => $this->user->id(),
]);
$this->node->save();
$this->nodePreviousRevisionId = $this->node->getRevisionId();
$this->node->setNewRevision();
$this->node->setTitle('revised_dummy_title');
$this->node->save();
$this->node2 = Node::create([
'type' => 'dummy',
'title' => 'Another test node',
'uid' => $this->user->id(),
]);
$this->node2->save();
$entity_type_manager = \Drupal::entityTypeManager();
$version_negotiator = new VersionNegotiator();
$version_negotiator->addVersionNegotiator(new VersionById($entity_type_manager), 'id');
$version_negotiator->addVersionNegotiator(new VersionByRel($entity_type_manager), 'rel');
$this->versionNegotiator = $version_negotiator;
}
/**
* @covers \Drupal\jsonapi\Revisions\VersionById::getRevision
*/
public function testOldRevision() {
$revision = $this->versionNegotiator->getRevision($this->node, 'id:' . $this->nodePreviousRevisionId);
$this->assertEquals($this->node->id(), $revision->id());
$this->assertEquals($this->nodePreviousRevisionId, $revision->getRevisionId());
}
/**
* @covers \Drupal\jsonapi\Revisions\VersionById::getRevision
*/
public function testInvalidRevisionId() {
$this->expectException(CacheableNotFoundHttpException::class);
$this->expectExceptionMessage(sprintf('The requested version, identified by `id:%s`, could not be found.', $this->node2->getRevisionId()));
$this->versionNegotiator->getRevision($this->node, 'id:' . $this->node2->getRevisionId());
}
/**
* @covers \Drupal\jsonapi\Revisions\VersionByRel::getRevision
*/
public function testLatestVersion() {
$revision = $this->versionNegotiator->getRevision($this->node, 'rel:' . VersionByRel::LATEST_VERSION);
$this->assertEquals($this->node->id(), $revision->id());
$this->assertEquals($this->node->getRevisionId(), $revision->getRevisionId());
}
/**
* @covers \Drupal\jsonapi\Revisions\VersionByRel::getRevision
*/
public function testCurrentVersion() {
$revision = $this->versionNegotiator->getRevision($this->node, 'rel:' . VersionByRel::WORKING_COPY);
$this->assertEquals($this->node->id(), $revision->id());
$this->assertEquals($this->node->id(), $revision->id());
$this->assertEquals($this->node->getRevisionId(), $revision->getRevisionId());
}
/**
* @covers \Drupal\jsonapi\Revisions\VersionByRel::getRevision
*/
public function testInvalidRevisionRel() {
$this->expectException(CacheableBadRequestHttpException::class);
$this->expectExceptionMessage('An invalid resource version identifier, `rel:erroneous-revision-name`, was provided.');
$this->versionNegotiator->getRevision($this->node, 'rel:erroneous-revision-name');
}
}

Some files were not shown because too many files have changed in this diff Show More