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 {
* {@inheritdoc}
*/
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'])) {
$values['lang'] = $context['langcode'];
}
......
......@@ -2,16 +2,7 @@
namespace Drupal\Tests\hal\Kernel;
use Drupal\Core\Cache\MemoryBackend;
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.
......@@ -33,20 +24,6 @@ class FileNormalizeTest extends NormalizerTestBase {
protected function setUp() {
parent::setUp();
$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 @@
namespace Drupal\Tests\hal\Kernel;
use Drupal\Core\Cache\MemoryBackend;
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\serialization\EntityResolver\ChainEntityResolver;
use Drupal\serialization\EntityResolver\TargetIdResolver;
use Drupal\serialization\EntityResolver\UuidResolver;
use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\Serializer\Serializer;
use Drupal\field\Entity\FieldStorageConfig;
/**
......@@ -130,23 +117,7 @@ protected function setUp() {
'translatable' => TRUE,
])->save();
$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')));
$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);
$this->serializer = $this->container->get('serializer');
}
}
......@@ -11,6 +11,8 @@
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\TypedData\PrimitiveInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Psr\Log\LoggerInterface;
......@@ -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.
*
......@@ -239,7 +276,7 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity
}
// 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;
}
// It is not possible to set the language to NULL as it is automatically
......
......@@ -144,17 +144,17 @@ protected function getExpectedNormalizedEntity() {
],
'status' => [
[
'value' => 1,
'value' => TRUE,
],
],
'created' => [
[
'value' => '123456789',
'value' => 123456789,
],
],
'changed' => [
[
'value' => (string) $this->entity->getChangedTime(),
'value' => $this->entity->getChangedTime(),
],
],
'default_langcode' => [
......@@ -164,7 +164,7 @@ protected function getExpectedNormalizedEntity() {
],
'uid' => [
[
'target_id' => $author->id(),
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(),
......@@ -178,7 +178,7 @@ protected function getExpectedNormalizedEntity() {
],
'entity_id' => [
[
'target_id' => '1',
'target_id' => 1,
'target_type' => 'entity_test',
'target_uuid' => EntityTest::load(1)->uuid(),
'url' => base_path() . 'entity_test/1',
......
......@@ -406,11 +406,15 @@ public function testGet() {
$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];
$this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value));
// Comparing the exact serialization is pointless, because the order of
// fields does not matter (at least not yet). That's why we only compare the
// normalized entity with the decoded response: it's comparing PHP arrays
// instead of strings.
$this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string) $response->getBody(), static::$format));
// Sort the serialization data first so we can do an identical comparison
// for the keys with the array order the same (it needs to match with
// identical comparison).
$expected = $this->getExpectedNormalizedEntity();
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
// response results in the expected object.
$unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
......@@ -448,7 +452,35 @@ public function testGet() {
}
$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->refreshTestStateAfterRestConfigChange();
......@@ -511,6 +543,30 @@ public function testGet() {
$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.
*/
......
......@@ -73,7 +73,7 @@ protected function getExpectedNormalizedEntity() {
],
'id' => [
[
'value' => '1',
'value' => 1,
],
],
'langcode' => [
......@@ -93,12 +93,12 @@ protected function getExpectedNormalizedEntity() {
],
'created' => [
[
'value' => $this->entity->get('created')->value,
'value' => (int) $this->entity->get('created')->value,
]
],
'user_id' => [
[
'target_id' => $author->id(),
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => $author->toUrl()->toString(),
......
......@@ -116,32 +116,32 @@ protected function getExpectedNormalizedEntity() {
],
'status' => [
[
'value' => 1,
'value' => TRUE,
],
],
'created' => [
[
'value' => '123456789',
'value' => 123456789,
],
],
'changed' => [
[
'value' => (string) $this->entity->getChangedTime(),
'value' => $this->entity->getChangedTime(),
],
],
'promote' => [
[
'value' => 1,
'value' => TRUE,
],
],
'sticky' => [
[
'value' => '0',
'value' => FALSE,
],
],
'revision_timestamp' => [
[
'value' => '123456789',
'value' => 123456789,
],
],
'revision_translation_affected' => [
......@@ -156,7 +156,7 @@ protected function getExpectedNormalizedEntity() {
],
'uid' => [
[
'target_id' => $author->id(),
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(),
......@@ -164,14 +164,13 @@ protected function getExpectedNormalizedEntity() {
],
'revision_uid' => [
[
'target_id' => $author->id(),
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(),
],
],
'revision_log' => [
],
'revision_log' => [],
];
}
......
......@@ -108,7 +108,7 @@ protected function getExpectedNormalizedEntity() {
],
'changed' => [
[
'value' => (string) $this->entity->getChangedTime(),
'value' => $this->entity->getChangedTime(),
],
],
'default_langcode' => [
......
......@@ -82,7 +82,7 @@ protected function createEntity() {
protected function getExpectedNormalizedEntity() {
return [
'uid' => [
['value' => '3'],
['value' => 3],
],
'uuid' => [
['value' => $this->entity->uuid()],
......@@ -99,12 +99,12 @@ protected function getExpectedNormalizedEntity() {
],
'created' => [
[
'value' => '123456789',
'value' => 123456789,
],
],
'changed' => [
[
'value' => (string) $this->entity->getChangedTime(),
'value' => $this->entity->getChangedTime(),
],
],
'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 @@
* 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
* @{
......@@ -16,6 +41,18 @@
*/
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".
*/
......@@ -17,6 +17,10 @@ services:
tags:
- { name: normalizer }
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:
class: Drupal\serialization\Normalizer\ComplexDataNormalizer
tags:
......@@ -89,3 +93,8 @@ services:
tags:
- { name: event_subscriber }
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;
}
/**
* Invalidates the service container if serialization BC config gets updated.
*
* @param \Drupal\Core\Config\ConfigCrudEvent $event
*/
public function onConfigSave(ConfigCrudEvent $event) {
$saved_config = $event->getConfig();
if ($saved_config->getName() === 'serialization.settings') {
if ($event->isChanged('bc_primitives_as_strings')) {
$this->kernel->invalidateContainer();
}
}
}
}
......@@ -26,6 +26,7 @@ class ComplexDataNormalizer extends NormalizerBase {
*/
public function normalize($object, $format = NULL, array $context = array()) {
$attributes = array();
/** @var \Drupal\Core\TypedData\TypedDataInterface $field */
foreach ($object as $name => $field) {
$attributes[$name] = $this->serializer->normalize($field, $format, $context);
}
......
<?php
namespace Drupal\serialization\Normalizer;
use Drupal\Core\TypedData\PrimitiveInterface;
/**
* Converts primitive data objects to their casted values.
*/
class PrimitiveDataNormalizer extends NormalizerBase {
/**
* The interface or class that this Normalizer supports.
*
* @var string
*/
protected $supportedInterfaceOrClass = PrimitiveInterface::class;
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []) {
// Typed data casts NULL objects to their empty variants, so for example
// the empty string ('') for string type data, or 0 for integer typed data.
// In a better world with typed data implementing algebraic data types,
// getCastedValue would return NULL, but as typed data is not aware of real
// optional values on the primitive level, we implement our own optional
// value normalization here.
return $object->getValue() === NULL ? NULL : $object->getCastedValue();
}
}
......@@ -2,6 +2,7 @@
namespace Drupal\serialization;
use Drupal\Core\Config\BootstrapConfigStorageFactory;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
......@@ -22,6 +23,12 @@ public function process(ContainerBuilder $container) {
// Retrieve registered Normalizers and Encoders from the container.
foreach ($container->findTaggedServiceIds('normalizer') as $id => $attributes) {
// If there is a BC key present, pass this to determine if the normalizer
// should be skipped.
if (isset($attributes[0]['bc']) && $this->normalizerBcSettingIsEnabled($attributes[0]['bc'], $attributes[0]['bc_config_name'])) {
continue;
}
$priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
$normalizers[$priority][] = new Reference($id);
}
......@@ -53,6 +60,18 @@ public function process(ContainerBuilder $container) {
$container->setParameter('serializer.format_providers', $format_providers);
}
/**
* Returns whether a normalizer BC setting is disabled or not.
*
* @param string $key
*
* @return bool
*/
protected function normalizerBcSettingIsEnabled($key, $config_name) {
$settings = BootstrapConfigStorageFactory::get()->read($config_name);
return !empty($settings[$key]);
}
/**
* Sorts by priority.
*
......
......@@ -110,7 +110,8 @@ public function testNormalize() {
),
'user_id' => array(
array(
'target_id' => $this->user->id(),
// id() will return the string value as it comes from the database.
'target_id' => (int) $this->user->id(),
'target_type' => $this->user->getEntityTypeId(),
'target_uuid' => $this->user->uuid(),
'url' => $this->user->url(),
......@@ -134,7 +135,7 @@ public function testNormalize() {
$normalized = $this->serializer->normalize($this->entity);
foreach (array_keys($expected) as $fieldName) {
$this->assertEqual($expected[$fieldName], $normalized[$fieldName], "ComplexDataNormalizer produces expected array for $fieldName.");
$this->assertSame($expected[$fieldName], $normalized[$fieldName], "Normalization produces expected array for $fieldName.");
}
$this->assertEqual(array_diff_key($normalized, $expected), array(), 'No unexpected data is added to the normalized array.');
}
......
<?php
namespace Drupal\Tests\serialization\Unit\Normalizer;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\Core\TypedData\Plugin\DataType\BooleanData;
use Drupal\Core\TypedData\Plugin\DataType\IntegerData;
use Drupal\Core\TypedData\Plugin\DataType\StringData;
use Drupal\Tests\UnitTestCase;
use Drupal\serialization\Normalizer\PrimitiveDataNormalizer;
/**
* @coversDefaultClass \Drupal\serialization\Normalizer\PrimitiveDataNormalizer
* @group serialization
*/
class PrimitiveDataNormalizerTest extends UnitTestCase {
/**
* The TypedDataNormalizer instance.
*
* @var \Drupal\serialization\Normalizer\TypedDataNormalizer
*/
protected $normalizer;
/**
* {@inheritdoc}
*/
protected function setUp() {
$this->normalizer = new PrimitiveDataNormalizer();
}