Commit 6cca8a6d authored by xjm's avatar xjm

Issue #2751325 by damiankloip, Grayside, dawehner, Wim Leers, catch, tedbow,...

Issue #2751325 by damiankloip, Grayside, dawehner, Wim Leers, catch, tedbow, alexpott, himanshu-dixit, Jo Fitzgerald, xjm, andrewbelcher, skyredwang, effulgentsia, hampercm, eelkeblok: All serialized values are strings, should be integers/booleans when appropriate
parent b1242b29
...@@ -21,7 +21,14 @@ class FieldItemNormalizer extends NormalizerBase { ...@@ -21,7 +21,14 @@ class FieldItemNormalizer extends NormalizerBase {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function normalize($field_item, $format = NULL, array $context = array()) { public function normalize($field_item, $format = NULL, array $context = array()) {
$values = $field_item->toArray(); $values = [];
// We normalize each individual property, so each can do their own casting,
// if needed.
/** @var \Drupal\Core\TypedData\TypedDataInterface $property */
foreach ($field_item as $property_name => $property) {
$values[$property_name] = $this->serializer->normalize($property, $format, $context);
}
if (isset($context['langcode'])) { if (isset($context['langcode'])) {
$values['lang'] = $context['langcode']; $values['lang'] = $context['langcode'];
} }
......
...@@ -2,16 +2,7 @@ ...@@ -2,16 +2,7 @@
namespace Drupal\Tests\hal\Kernel; namespace Drupal\Tests\hal\Kernel;
use Drupal\Core\Cache\MemoryBackend;
use Drupal\file\Entity\File; use Drupal\file\Entity\File;
use Drupal\hal\Encoder\JsonEncoder;
use Drupal\hal\LinkManager\LinkManager;
use Drupal\hal\LinkManager\RelationLinkManager;
use Drupal\hal\LinkManager\TypeLinkManager;
use Drupal\hal\Normalizer\FieldItemNormalizer;
use Drupal\hal\Normalizer\FileEntityNormalizer;
use Symfony\Component\Serializer\Serializer;
/** /**
* Tests that file entities can be normalized in HAL. * Tests that file entities can be normalized in HAL.
...@@ -33,20 +24,6 @@ class FileNormalizeTest extends NormalizerTestBase { ...@@ -33,20 +24,6 @@ class FileNormalizeTest extends NormalizerTestBase {
protected function setUp() { protected function setUp() {
parent::setUp(); parent::setUp();
$this->installEntitySchema('file'); $this->installEntitySchema('file');
$entity_manager = \Drupal::entityManager();
$link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend(), \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack'), \Drupal::service('entity_type.bundle.info')), new RelationLinkManager(new MemoryBackend(), $entity_manager, \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')));
// Set up the mock serializer.
$normalizers = array(
new FieldItemNormalizer(),
new FileEntityNormalizer($entity_manager, \Drupal::httpClient(), $link_manager, \Drupal::moduleHandler()),
);
$encoders = array(
new JsonEncoder(),
);
$this->serializer = new Serializer($normalizers, $encoders);
} }
......
...@@ -2,22 +2,9 @@ ...@@ -2,22 +2,9 @@
namespace Drupal\Tests\hal\Kernel; namespace Drupal\Tests\hal\Kernel;
use Drupal\Core\Cache\MemoryBackend;
use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldConfig;
use Drupal\hal\Encoder\JsonEncoder;
use Drupal\hal\LinkManager\LinkManager;
use Drupal\hal\LinkManager\RelationLinkManager;
use Drupal\hal\LinkManager\TypeLinkManager;
use Drupal\hal\Normalizer\ContentEntityNormalizer;
use Drupal\hal\Normalizer\EntityReferenceItemNormalizer;
use Drupal\hal\Normalizer\FieldItemNormalizer;
use Drupal\hal\Normalizer\FieldNormalizer;
use Drupal\language\Entity\ConfigurableLanguage; use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\serialization\EntityResolver\ChainEntityResolver;
use Drupal\serialization\EntityResolver\TargetIdResolver;
use Drupal\serialization\EntityResolver\UuidResolver;
use Drupal\KernelTests\KernelTestBase; use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\Serializer\Serializer;
use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldStorageConfig;
/** /**
...@@ -130,23 +117,7 @@ protected function setUp() { ...@@ -130,23 +117,7 @@ protected function setUp() {
'translatable' => TRUE, 'translatable' => TRUE,
])->save(); ])->save();
$entity_manager = \Drupal::entityManager(); $this->serializer = $this->container->get('serializer');
$link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend(), \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack'), \Drupal::service('entity_type.bundle.info')), new RelationLinkManager(new MemoryBackend(), $entity_manager, \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')));
$chain_resolver = new ChainEntityResolver(array(new UuidResolver($entity_manager), new TargetIdResolver()));
// Set up the mock serializer.
$normalizers = array(
new ContentEntityNormalizer($link_manager, $entity_manager, \Drupal::moduleHandler()),
new EntityReferenceItemNormalizer($link_manager, $chain_resolver),
new FieldItemNormalizer(),
new FieldNormalizer(),
);
$encoders = array(
new JsonEncoder(),
);
$this->serializer = new Serializer($normalizers, $encoders);
} }
} }
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\TypedData\PrimitiveInterface;
use Drupal\rest\Plugin\ResourceBase; use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse; use Drupal\rest\ResourceResponse;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
...@@ -195,6 +197,41 @@ public function post(EntityInterface $entity = NULL) { ...@@ -195,6 +197,41 @@ public function post(EntityInterface $entity = NULL) {
} }
} }
/**
* Gets the values from the field item list casted to the correct type.
*
* Values are casted to the correct type so we can determine whether or not
* something has changed. REST formats such as JSON support typed data but
* Drupal's database API will return values as strings. Currently, only
* primitive data types know how to cast their values to the correct type.
*
* @param \Drupal\Core\Field\FieldItemListInterface $field_item_list
* The field item list to retrieve its data from.
*
* @return mixed[][]
* The values from the field item list casted to the correct type. The array
* of values returned is a multidimensional array keyed by delta and the
* property name.
*/
protected function getCastedValueFromFieldItemList(FieldItemListInterface $field_item_list) {
$value = $field_item_list->getValue();
foreach ($value as $delta => $field_item_value) {
/** @var \Drupal\Core\Field\FieldItemInterface $field_item */
$field_item = $field_item_list->get($delta);
$properties = $field_item->getProperties(TRUE);
// Foreach field value we check whether we know the underlying property.
// If we exists we try to cast the value.
foreach ($field_item_value as $property_name => $property_value) {
if (isset($properties[$property_name]) && ($property = $field_item->get($property_name)) && $property instanceof PrimitiveInterface) {
$value[$delta][$property_name] = $property->getCastedValue();
}
}
}
return $value;
}
/** /**
* Responds to entity PATCH requests. * Responds to entity PATCH requests.
* *
...@@ -239,7 +276,7 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity ...@@ -239,7 +276,7 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity
} }
// Unchanged values for entity keys don't need access checking. // Unchanged values for entity keys don't need access checking.
if ($original_entity->get($field_name)->getValue() === $entity->get($field_name)->getValue()) { if ($this->getCastedValueFromFieldItemList($original_entity->get($field_name)) === $this->getCastedValueFromFieldItemList($entity->get($field_name))) {
continue; continue;
} }
// It is not possible to set the language to NULL as it is automatically // It is not possible to set the language to NULL as it is automatically
......
...@@ -144,17 +144,17 @@ protected function getExpectedNormalizedEntity() { ...@@ -144,17 +144,17 @@ protected function getExpectedNormalizedEntity() {
], ],
'status' => [ 'status' => [
[ [
'value' => 1, 'value' => TRUE,
], ],
], ],
'created' => [ 'created' => [
[ [
'value' => '123456789', 'value' => 123456789,
], ],
], ],
'changed' => [ 'changed' => [
[ [
'value' => (string) $this->entity->getChangedTime(), 'value' => $this->entity->getChangedTime(),
], ],
], ],
'default_langcode' => [ 'default_langcode' => [
...@@ -164,7 +164,7 @@ protected function getExpectedNormalizedEntity() { ...@@ -164,7 +164,7 @@ protected function getExpectedNormalizedEntity() {
], ],
'uid' => [ 'uid' => [
[ [
'target_id' => $author->id(), 'target_id' => (int) $author->id(),
'target_type' => 'user', 'target_type' => 'user',
'target_uuid' => $author->uuid(), 'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(), 'url' => base_path() . 'user/' . $author->id(),
...@@ -178,7 +178,7 @@ protected function getExpectedNormalizedEntity() { ...@@ -178,7 +178,7 @@ protected function getExpectedNormalizedEntity() {
], ],
'entity_id' => [ 'entity_id' => [
[ [
'target_id' => '1', 'target_id' => 1,
'target_type' => 'entity_test', 'target_type' => 'entity_test',
'target_uuid' => EntityTest::load(1)->uuid(), 'target_uuid' => EntityTest::load(1)->uuid(),
'url' => base_path() . 'entity_test/1', 'url' => base_path() . 'entity_test/1',
......
...@@ -406,11 +406,15 @@ public function testGet() { ...@@ -406,11 +406,15 @@ public function testGet() {
$this->assertEquals($this->getExpectedCacheTags(), empty($cache_tags_header_value) ? [] : explode(' ', $cache_tags_header_value)); $this->assertEquals($this->getExpectedCacheTags(), empty($cache_tags_header_value) ? [] : explode(' ', $cache_tags_header_value));
$cache_contexts_header_value = $response->getHeader('X-Drupal-Cache-Contexts')[0]; $cache_contexts_header_value = $response->getHeader('X-Drupal-Cache-Contexts')[0];
$this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value)); $this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value));
// Comparing the exact serialization is pointless, because the order of // Sort the serialization data first so we can do an identical comparison
// fields does not matter (at least not yet). That's why we only compare the // for the keys with the array order the same (it needs to match with
// normalized entity with the decoded response: it's comparing PHP arrays // identical comparison).
// instead of strings. $expected = $this->getExpectedNormalizedEntity();
$this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string) $response->getBody(), static::$format)); ksort($expected);
$actual = $this->serializer->decode((string) $response->getBody(), static::$format);
ksort($actual);
$this->assertSame($expected, $actual);
// Not only assert the normalization, also assert deserialization of the // Not only assert the normalization, also assert deserialization of the
// response results in the expected object. // response results in the expected object.
$unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format); $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
...@@ -448,7 +452,35 @@ public function testGet() { ...@@ -448,7 +452,35 @@ public function testGet() {
} }
$this->assertSame($get_headers, $head_headers); $this->assertSame($get_headers, $head_headers);
// Only run this for fieldable entities. It doesn't make sense for config
// entities as config values are already casted. They also run through the
// ConfigEntityNormalizer, which doesn't deal with fields individually.
if ($this->entity instanceof FieldableEntityInterface) {
$this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE);
// Rebuild the container so new config is reflected in the removal of the
// PrimitiveDataNormalizer.
$this->rebuildAll();
$response = $this->request('GET', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response);
// Again do an identical comparison, but this time transform the expected
// normalized entity's values to strings. This ensures the BC layer for
// bc_primitives_as_strings works as expected.
$expected = $this->getExpectedNormalizedEntity();
// Config entities are not affected.
// @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
$expected = static::castToString($expected);
ksort($expected);
$actual = $this->serializer->decode((string) $response->getBody(), static::$format);
ksort($actual);
$this->assertSame($expected, $actual);
}
// BC: rest_update_8203().
$this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
$this->refreshTestStateAfterRestConfigChange(); $this->refreshTestStateAfterRestConfigChange();
...@@ -511,6 +543,30 @@ public function testGet() { ...@@ -511,6 +543,30 @@ public function testGet() {
$this->assertResourceErrorResponse(404, $message, $response); $this->assertResourceErrorResponse(404, $message, $response);
} }
/**
* Transforms a normalization: casts all non-string types to strings.
*
* @param array $normalization
* A normalization to transform.
*
* @return array
* The transformed normalization.
*/
protected static function castToString(array $normalization) {
foreach ($normalization as $key => $value) {
if (is_bool($value)) {
$normalization[$key] = (string) (int) $value;
}
elseif (is_int($value) || is_float($value)) {
$normalization[$key] = (string) $value;
}
elseif (is_array($value)) {
$normalization[$key] = static::castToString($value);
}
}
return $normalization;
}
/** /**
* Tests a POST request for an entity, plus edge cases to ensure good DX. * Tests a POST request for an entity, plus edge cases to ensure good DX.
*/ */
......
...@@ -73,7 +73,7 @@ protected function getExpectedNormalizedEntity() { ...@@ -73,7 +73,7 @@ protected function getExpectedNormalizedEntity() {
], ],
'id' => [ 'id' => [
[ [
'value' => '1', 'value' => 1,
], ],
], ],
'langcode' => [ 'langcode' => [
...@@ -93,12 +93,12 @@ protected function getExpectedNormalizedEntity() { ...@@ -93,12 +93,12 @@ protected function getExpectedNormalizedEntity() {
], ],
'created' => [ 'created' => [
[ [
'value' => $this->entity->get('created')->value, 'value' => (int) $this->entity->get('created')->value,
] ]
], ],
'user_id' => [ 'user_id' => [
[ [
'target_id' => $author->id(), 'target_id' => (int) $author->id(),
'target_type' => 'user', 'target_type' => 'user',
'target_uuid' => $author->uuid(), 'target_uuid' => $author->uuid(),
'url' => $author->toUrl()->toString(), 'url' => $author->toUrl()->toString(),
......
...@@ -116,32 +116,32 @@ protected function getExpectedNormalizedEntity() { ...@@ -116,32 +116,32 @@ protected function getExpectedNormalizedEntity() {
], ],
'status' => [ 'status' => [
[ [
'value' => 1, 'value' => TRUE,
], ],
], ],
'created' => [ 'created' => [
[ [
'value' => '123456789', 'value' => 123456789,
], ],
], ],
'changed' => [ 'changed' => [
[ [
'value' => (string) $this->entity->getChangedTime(), 'value' => $this->entity->getChangedTime(),
], ],
], ],
'promote' => [ 'promote' => [
[ [
'value' => 1, 'value' => TRUE,
], ],
], ],
'sticky' => [ 'sticky' => [
[ [
'value' => '0', 'value' => FALSE,
], ],
], ],
'revision_timestamp' => [ 'revision_timestamp' => [
[ [
'value' => '123456789', 'value' => 123456789,
], ],
], ],
'revision_translation_affected' => [ 'revision_translation_affected' => [
...@@ -156,7 +156,7 @@ protected function getExpectedNormalizedEntity() { ...@@ -156,7 +156,7 @@ protected function getExpectedNormalizedEntity() {
], ],
'uid' => [ 'uid' => [
[ [
'target_id' => $author->id(), 'target_id' => (int) $author->id(),
'target_type' => 'user', 'target_type' => 'user',
'target_uuid' => $author->uuid(), 'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(), 'url' => base_path() . 'user/' . $author->id(),
...@@ -164,14 +164,13 @@ protected function getExpectedNormalizedEntity() { ...@@ -164,14 +164,13 @@ protected function getExpectedNormalizedEntity() {
], ],
'revision_uid' => [ 'revision_uid' => [
[ [
'target_id' => $author->id(), 'target_id' => (int) $author->id(),
'target_type' => 'user', 'target_type' => 'user',
'target_uuid' => $author->uuid(), 'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(), 'url' => base_path() . 'user/' . $author->id(),
], ],
], ],
'revision_log' => [ 'revision_log' => [],
],
]; ];
} }
......
...@@ -108,7 +108,7 @@ protected function getExpectedNormalizedEntity() { ...@@ -108,7 +108,7 @@ protected function getExpectedNormalizedEntity() {
], ],
'changed' => [ 'changed' => [
[ [
'value' => (string) $this->entity->getChangedTime(), 'value' => $this->entity->getChangedTime(),
], ],
], ],
'default_langcode' => [ 'default_langcode' => [
......
...@@ -82,7 +82,7 @@ protected function createEntity() { ...@@ -82,7 +82,7 @@ protected function createEntity() {
protected function getExpectedNormalizedEntity() { protected function getExpectedNormalizedEntity() {
return [ return [
'uid' => [ 'uid' => [
['value' => '3'], ['value' => 3],
], ],
'uuid' => [ 'uuid' => [
['value' => $this->entity->uuid()], ['value' => $this->entity->uuid()],
...@@ -99,12 +99,12 @@ protected function getExpectedNormalizedEntity() { ...@@ -99,12 +99,12 @@ protected function getExpectedNormalizedEntity() {
], ],
'created' => [ 'created' => [
[ [
'value' => '123456789', 'value' => 123456789,
], ],
], ],
'changed' => [ 'changed' => [
[ [
'value' => (string) $this->entity->getChangedTime(), 'value' => $this->entity->getChangedTime(),
], ],
], ],
'default_langcode' => [ 'default_langcode' => [
......
# Before Drupal 8.3, typed data primitive values were normalized as strings, as
# this was usually returned from database storage. A primitive data normalizer
# has been introduced to get the casted value instead.
bc_primitives_as_strings: false
serialization.settings:
type: config_object
label: 'Serialization settings'
mapping:
bc_primitives_as_strings:
type: boolean
label: 'Whether to retain pre Drupal 8.3 behavior of serializing all primitive items as strings.'
...@@ -5,6 +5,31 @@ ...@@ -5,6 +5,31 @@
* Update functions for the Serialization module. * Update functions for the Serialization module.
*/ */
/**
* Implements hook_requirements().
*/
function serialization_requirements($phase) {
$requirements = [];
if ($phase == 'runtime') {
$requirements['serialization_as_strings'] = array(
'title' => t('Serialized data types'),
'severity' => REQUIREMENT_INFO,
);
if (\Drupal::config('serialization.settings')->get('bc_primitives_as_strings')) {
$requirements['serialization_as_strings']['value'] = t('Enabled');
$requirements['serialization_as_strings']['description'] = t('The Serialization API is configured to output only string values for REST and other applications (instead of integers or Booleans when appropriate). <a href="https://www.drupal.org/node/2837696">Disabling this backwards compatibility mode</a> is recommended unless your sites or applications require string output.');
}
else {
$requirements['serialization_as_strings']['value'] = t('Not enabled');
$requirements['serialization_as_strings']['description'] = t('The Serialization API is configured with the recommended default and outputs typed values (integers, Booleans, or strings as appropriate) for REST and other applications. If your site or applications require string output, you can <a href="https://www.drupal.org/node/2837696">enable backwards compatibility mode</a>.');
}
}
return $requirements;
}
/** /**
* @defgroup updates-8.2.x-to-8.3.x Updates from 8.2.x to 8.3.x * @defgroup updates-8.2.x-to-8.3.x Updates from 8.2.x to 8.3.x
* @{ * @{
...@@ -16,6 +41,18 @@ ...@@ -16,6 +41,18 @@
*/ */
function serialization_update_8301() {} function serialization_update_8301() {}
/**
* Add serialization.settings::bc_primitives_as_strings configuration.
*/
function serialization_update_8302() {
$config_factory = \Drupal::configFactory();
$config_factory->getEditable('serialization.settings')
->set('bc_primitives_as_strings', FALSE)
->save(FALSE);
return t('The REST API will no longer output all values as strings. Integers/booleans will be used where appropriate. If your site depends on these value being strings, <a href="https://www.drupal.org/node/2837696">read the change record to learn how to enable the BC mode.</a>');
}
/** /**
* @} End of "defgroup updates-8.2.x-to-8.3.x". * @} End of "defgroup updates-8.2.x-to-8.3.x".
*/ */
...@@ -17,6 +17,10 @@ services: ...@@ -17,6 +17,10 @@ services:
tags: tags:
- { name: normalizer } - { name: normalizer }
arguments: ['@entity.manager'] arguments: ['@entity.manager']
serializer.normalizer.primitive_data:
class: Drupal\serialization\Normalizer\PrimitiveDataNormalizer
tags:
- { name: normalizer, priority: 5, bc: bc_primitives_as_strings, bc_config_name: 'serialization.settings' }
serializer.normalizer.complex_data: serializer.normalizer.complex_data:
class: Drupal\serialization\Normalizer\ComplexDataNormalizer class: Drupal\serialization\Normalizer\ComplexDataNormalizer
tags: tags:
...@@ -89,3 +93,8 @@ services: ...@@ -89,3 +93,8 @@ services:
tags: tags:
- { name: event_subscriber } - { name: event_subscriber }
arguments: ['%serializer.formats%'] arguments: ['%serializer.formats%']
serialization.bc_config_subscriber:
class: Drupal\serialization\EventSubscriber\BcConfigSubscriber
tags:
- { name: event_subscriber }
arguments: ['@kernel']
<?php
namespace Drupal\serialization\EventSubscriber;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\DrupalKernelInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Config event subscriber to rebuild the container when BC config is saved.
*/
class BcConfigSubscriber implements EventSubscriberInterface {
/**
* The Drupal Kernel.
*
* @var \Drupal\Core\DrupalKernelInterface
*/
protected $kernel;
/**
* BcConfigSubscriber constructor.
*
* @param \Drupal\Core\DrupalKernelInterface $kernel
* The Drupal Kernel.
*/
public function __construct(DrupalKernelInterface $kernel) {
$this->kernel = $kernel;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[ConfigEvents::SAVE][] = 'onConfigSave';
return $events;
}
/**