diff --git a/core/modules/hal/hal.services.yml b/core/modules/hal/hal.services.yml index 97fcbfb13dcd4ddcf794684d91466d7c99af5a45..b2c898fc5608c809f76046b269cc8cd82e7894fd 100644 --- a/core/modules/hal/hal.services.yml +++ b/core/modules/hal/hal.services.yml @@ -17,6 +17,11 @@ services: tags: - { name: normalizer, priority: 20 } arguments: ['@entity.manager', '@http_client', '@hal.link_manager', '@module_handler'] + serializer.normalizer.timestamp_item.hal: + class: Drupal\hal\Normalizer\TimestampItemNormalizer + tags: + # Priority must be higher than serializer.normalizer.field_item.hal. + - { name: normalizer, priority: 20, bc: bc_timestamp_normalizer_unix, bc_config_name: 'serialization.settings' } serializer.normalizer.entity.hal: class: Drupal\hal\Normalizer\ContentEntityNormalizer arguments: ['@hal.link_manager', '@entity.manager', '@module_handler'] diff --git a/core/modules/hal/src/Normalizer/FieldItemNormalizer.php b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php index dc5aec98994ada8055135dff94b6680b0ed271ea..0c2ee9ed4d9a7c69b1bf3c9b7b7bb8a26d9a9d7e 100644 --- a/core/modules/hal/src/Normalizer/FieldItemNormalizer.php +++ b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php @@ -21,25 +21,13 @@ class FieldItemNormalizer extends NormalizerBase { * {@inheritdoc} */ public function normalize($field_item, $format = NULL, array $context = []) { - $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']; - } - // The values are wrapped in an array, and then wrapped in another array // keyed by field name so that field items can be merged by the // FieldNormalizer. This is necessary for the EntityReferenceItemNormalizer // to be able to place values in the '_links' array. $field = $field_item->getParent(); return [ - $field->getName() => [$values], + $field->getName() => [$this->normalizedFieldValues($field_item, $format, $context)], ]; } @@ -85,6 +73,35 @@ protected function constructValue($data, $context) { return $data; } + /** + * Normalizes field values for an item. + * + * @param \Drupal\Core\Field\FieldItemInterface $field_item + * The field item instance. + * @param string|null $format + * The normalization format. + * @param array $context + * The context passed into the normalizer. + * + * @return array + * An array of field item values, keyed by property name. + */ + protected function normalizedFieldValues(FieldItemInterface $field_item, $format, array $context) { + $denormalized = []; + // 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) { + $denormalized[$property_name] = $this->serializer->normalize($property, $format, $context); + } + + if (isset($context['langcode'])) { + $denormalized['lang'] = $context['langcode']; + } + + return $denormalized; + } + /** * Get a translated version of the field item instance. * diff --git a/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php b/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php new file mode 100644 index 0000000000000000000000000000000000000000..c389e793501dd1c7fdce32c8e6be65dcba10a826 --- /dev/null +++ b/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php @@ -0,0 +1,31 @@ +processNormalizedValues($normalized); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php index d4ed47c37c1ba58366f6bff66575e7d06da59b96..aeac85ab61f4e2e1e2d50a46b866585cf4bf8565 100644 --- a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php @@ -34,11 +34,11 @@ class NodeHalJsonAnonTest extends NodeResourceTestBase { * {@inheritdoc} */ protected static $patchProtectedFieldNames = [ + 'revision_timestamp', 'created', 'changed', 'promote', 'sticky', - 'revision_timestamp', 'revision_uid', ]; diff --git a/core/modules/hal/tests/src/Kernel/EntityTranslationNormalizeTest.php b/core/modules/hal/tests/src/Kernel/EntityTranslationNormalizeTest.php index 5b752f7bb6ab7597ed76dccdd24c09068f27fec6..2597af4affc13bd1e1ab077573a287a8e7c14c06 100644 --- a/core/modules/hal/tests/src/Kernel/EntityTranslationNormalizeTest.php +++ b/core/modules/hal/tests/src/Kernel/EntityTranslationNormalizeTest.php @@ -43,7 +43,7 @@ public function testNodeTranslation() { $node = Node::create([ 'title' => $this->randomMachineName(), - 'uid' => $user->id(), + 'uid' => (int) $user->id(), 'type' => $node_type->id(), 'status' => NodeInterface::PUBLISHED, 'langcode' => 'en', diff --git a/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php b/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..a275f646f7426a519506cbc458f83b8408f772da --- /dev/null +++ b/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php @@ -0,0 +1,43 @@ +config('serialization.settings')->get('bc_timestamp_normalizer_unix')) { + return ['value' => $timestamp]; + } + + // Otherwise, format the date string to the same that + // \Drupal\serialization\Normalizer\TimestampItemNormalizer will produce. + $date = new \DateTime(); + $date->setTimestamp($timestamp); + $date->setTimezone(new \DateTimeZone('UTC')); + + // Format is also added to the expected return values. + return [ + 'value' => $date->format(\DateTime::RFC3339), + 'format' => \DateTime::RFC3339, + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php index 961441213af3008e75bab3557649873362eaa96e..7b65c848b7830337f7c294a7c385448e4901eff6 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php @@ -6,13 +6,14 @@ use Drupal\comment\Entity\CommentType; use Drupal\comment\Tests\CommentTestTrait; use Drupal\entity_test\Entity\EntityTest; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; use Drupal\user\Entity\User; use GuzzleHttp\RequestOptions; abstract class CommentResourceTestBase extends EntityResourceTestBase { - use CommentTestTrait; + use CommentTestTrait, BcTimestampNormalizerUnixTestTrait; /** * {@inheritdoc} @@ -148,14 +149,10 @@ protected function getExpectedNormalizedEntity() { ], ], 'created' => [ - [ - 'value' => 123456789, - ], + $this->formatExpectedTimestampItemValues(123456789), ], 'changed' => [ - [ - 'value' => $this->entity->getChangedTime(), - ], + $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), ], 'default_langcode' => [ [ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 6e34de2bf56ebfe759c9445e3f3cd1ef828b3a51..277c47042be4f36fbd1d92bcd1d99a1afa46be30 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -465,9 +465,9 @@ public function testGet() { // for the keys with the array order the same (it needs to match with // identical comparison). $expected = $this->getExpectedNormalizedEntity(); - ksort($expected); + static::recursiveKSort($expected); $actual = $this->serializer->decode((string) $response->getBody(), static::$format); - ksort($actual); + static::recursiveKSort($actual); $this->assertSame($expected, $actual); // Not only assert the normalization, also assert deserialization of the @@ -507,12 +507,14 @@ public function testGet() { } $this->assertSame($get_headers, $head_headers); + // BC: serialization_update_8302(). // 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) { + // Test primitive data casting BC (strings). $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 + // Rebuild the container so new config is reflected in the addition of the // PrimitiveDataNormalizer. $this->rebuildAll(); @@ -528,10 +530,48 @@ public function testGet() { // Config entities are not affected. // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize() $expected = static::castToString($expected); - ksort($expected); + static::recursiveKSort($expected); $actual = $this->serializer->decode((string) $response->getBody(), static::$format); - ksort($actual); + static::recursiveKSort($actual); $this->assertSame($expected, $actual); + + // Reset the config value and rebuild. + $this->config('serialization.settings')->set('bc_primitives_as_strings', FALSE)->save(TRUE); + $this->rebuildAll(); + } + + // BC: serialization_update_8401(). + // Only run this for fieldable entities. It doesn't make sense for config + // entities as config values always use the raw values (as per the config + // schema), returned directly from the ConfigEntityNormalizer, which + // doesn't deal with fields individually. + if ($this->entity instanceof FieldableEntityInterface) { + // Test the BC settings for timestamp values. + $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE); + // Rebuild the container so new config is reflected in the addition of the + // TimestampItemNormalizer. + $this->rebuildAll(); + + + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + + + // This ensures the BC layer for bc_timestamp_normalizer_unix works as + // expected. This method should be using + // ::formatExpectedTimestampValue() to generate the timestamp value. This + // will take into account the above config setting. + $expected = $this->getExpectedNormalizedEntity(); + // Config entities are not affected. + // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize() + static::recursiveKSort($expected); + $actual = $this->serializer->decode((string) $response->getBody(), static::$format); + static::recursiveKSort($actual); + $this->assertSame($expected, $actual); + + // Reset the config value and rebuild. + $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE); + $this->rebuildAll(); } @@ -634,6 +674,27 @@ protected static function castToString(array $normalization) { return $normalization; } + /** + * Recursively sorts an array by key. + * + * @param array $array + * An array to sort. + * + * @return array + * The sorted array. + */ + protected static function recursiveKSort(array &$array) { + // First, sort the main array. + ksort($array); + + // Then check for child arrays. + foreach ($array as $key => &$value) { + if (is_array($value)) { + static::recursiveKSort($value); + } + } + } + /** * Tests a POST request for an entity, plus edge cases to ensure good DX. */ @@ -968,15 +1029,19 @@ public function testPatch() { // DX: 403 when sending PATCH request with read-only fields. - foreach (static::$patchProtectedFieldNames as $field_name) { - $normalization = $this->getNormalizedPatchEntity() + [$field_name => [['value' => $this->randomString()]]]; - $request_options[RequestOptions::BODY] = $this->serializer->serialize($normalization, static::$format); + // First send all fields (the "maximum normalization"). Assert the expected + // error message for the first PATCH-protected field. Remove that field from + // the normalization, send another request, assert the next PATCH-protected + // field error message. And so on. + $max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format); + for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) { + $max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i)); + $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format); $response = $this->request('PATCH', $url, $request_options); - $this->assertResourceErrorResponse(403, "Access denied on updating field '$field_name'.", $response); + $this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response); } // 200 for well-formed request that sends the maximum number of fields. - $max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format); $max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames); $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format); $response = $this->request('PATCH', $url, $request_options); diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php index 2935a00fa72faac6010c9fe07ced155a54559e8a..204c7ff378e6361b45eba5f18084504055a26997 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php @@ -3,11 +3,14 @@ namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest; use Drupal\entity_test\Entity\EntityTest; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; use Drupal\user\Entity\User; abstract class EntityTestResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -92,9 +95,7 @@ protected function getExpectedNormalizedEntity() { ] ], 'created' => [ - [ - 'value' => (int) $this->entity->get('created')->value, - ] + $this->formatExpectedTimestampItemValues((int) $this->entity->get('created')->value) ], 'user_id' => [ [ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php index d0307872f9691c555e5bc68c0900578d21355c14..26d0eca9de83567e9b2a96d259e301c21a24a265 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php @@ -3,11 +3,14 @@ namespace Drupal\Tests\rest\Functional\EntityResource\EntityTestLabel; use Drupal\entity_test\Entity\EntityTestLabel; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; use Drupal\user\Entity\User; abstract class EntityTestLabelResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -94,9 +97,7 @@ protected function getExpectedNormalizedEntity() { ], ], 'created' => [ - [ - 'value' => (int) $this->entity->get('created')->value, - ], + $this->formatExpectedTimestampItemValues((int) $this->entity->get('created')->value), ], 'user_id' => [ [ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php index f15a5e0554403c6cb524a616c54ed3fb5fb4f0bc..1e79395a605f5f6f905888c9fc2f1e91fe406ec0 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php @@ -2,11 +2,14 @@ namespace Drupal\Tests\rest\Functional\EntityResource\Feed; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase; use Drupal\aggregator\Entity\Feed; abstract class FeedResourceTestBase extends EntityTestResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -92,14 +95,10 @@ protected function getExpectedNormalizedEntity() { ] ], 'checked' => [ - [ - 'value' => 123456789 - ] + $this->formatExpectedTimestampItemValues(123456789), ], 'queued' => [ - [ - 'value' => 123456789 - ] + $this->formatExpectedTimestampItemValues(123456789), ], 'link' => [ [ @@ -127,9 +126,7 @@ protected function getExpectedNormalizedEntity() { ] ], 'modified' => [ - [ - 'value' => 123456789 - ] + $this->formatExpectedTimestampItemValues(123456789), ], ]; } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php index 9b72b66142e280af6d5795e5def31cdea655612d..f217b97fbac0f14da4509308895adf0ef2bff058 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php @@ -4,6 +4,7 @@ use Drupal\aggregator\Entity\Feed; use Drupal\aggregator\Entity\Item; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; /** @@ -11,6 +12,8 @@ */ abstract class ItemResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -113,9 +116,7 @@ protected function getExpectedNormalizedEntity() { 'author' => [], 'description' => [], 'timestamp' => [ - [ - 'value' => 123456789, - ], + $this->formatExpectedTimestampItemValues(123456789), ], 'guid' => [], ]; diff --git a/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php index f907d9a5983862b3e57862d601d96f17dad3cb92..0b1f967387e1b5e920ee061b34de3a2e4c1cef62 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\rest\Functional\EntityResource\MenuLinkContent; use Drupal\menu_link_content\Entity\MenuLinkContent; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; /** @@ -10,6 +11,8 @@ */ abstract class MenuLinkContentResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -161,9 +164,7 @@ protected function getExpectedNormalizedEntity() { ], ], 'changed' => [ - [ - 'value' => $this->entity->getChangedTime(), - ], + $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), ], 'default_langcode' => [ [ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php index 92fad1e39d020d4eb52c819f8c17cc8cb3340e63..53367b8fe07997bd42dadefe9a08001fd02c8871 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php @@ -4,11 +4,14 @@ use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; use Drupal\user\Entity\User; abstract class NodeResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -23,12 +26,12 @@ abstract class NodeResourceTestBase extends EntityResourceTestBase { * {@inheritdoc} */ protected static $patchProtectedFieldNames = [ + 'revision_timestamp', + 'revision_uid', 'created', 'changed', 'promote', 'sticky', - 'revision_timestamp', - 'revision_uid', ]; /** @@ -119,14 +122,10 @@ protected function getExpectedNormalizedEntity() { ], ], 'created' => [ - [ - 'value' => 123456789, - ], + $this->formatExpectedTimestampItemValues(123456789), ], 'changed' => [ - [ - 'value' => $this->entity->getChangedTime(), - ], + $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), ], 'promote' => [ [ @@ -139,9 +138,7 @@ protected function getExpectedNormalizedEntity() { ], ], 'revision_timestamp' => [ - [ - 'value' => 123456789, - ], + $this->formatExpectedTimestampItemValues(123456789), ], 'revision_translation_affected' => [ [ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php index 698451b58cf353948c7c7f0ed9dd628a11e2bad7..7dfe46e08d690cab47d50e79178c9a06a45197ff 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php @@ -4,10 +4,13 @@ use Drupal\taxonomy\Entity\Term; use Drupal\taxonomy\Entity\Vocabulary; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; abstract class TermResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -107,9 +110,7 @@ protected function getExpectedNormalizedEntity() { ], ], 'changed' => [ - [ - 'value' => $this->entity->getChangedTime(), - ], + $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), ], 'default_langcode' => [ [ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php index 0195ddaeb8174af275fadf40630b2c3c4b72dc78..25dcf75d23040051881edf0395ace37179772034 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php @@ -3,12 +3,15 @@ namespace Drupal\Tests\rest\Functional\EntityResource\User; use Drupal\Core\Url; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; use Drupal\user\Entity\User; use GuzzleHttp\RequestOptions; abstract class UserResourceTestBase extends EntityResourceTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * {@inheritdoc} */ @@ -98,14 +101,10 @@ protected function getExpectedNormalizedEntity() { ], ], 'created' => [ - [ - 'value' => 123456789, - ], + $this->formatExpectedTimestampItemValues(123456789), ], 'changed' => [ - [ - 'value' => $this->entity->getChangedTime(), - ], + $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), ], 'default_langcode' => [ [ diff --git a/core/modules/serialization/config/install/serialization.settings.yml b/core/modules/serialization/config/install/serialization.settings.yml index 39c583aaa63141944928e33c0b22feeed7f5f531..a2758c2d021f6b8735dfa580cbea5c9b4d20695f 100644 --- a/core/modules/serialization/config/install/serialization.settings.yml +++ b/core/modules/serialization/config/install/serialization.settings.yml @@ -2,3 +2,10 @@ # 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 +# Before Drupal 8.3, timestamps were always returned as Unix timestamps, which +# are not a universal format for interchange. Now, RFC3339 timestamps are +# returned. New Drupal installations opt out from this by default (hence this +# is set to false), existing installations opt in to it. +# @see serialization_update_8301() +# @see https://www.drupal.org/node/2768651 +bc_timestamp_normalizer_unix: false diff --git a/core/modules/serialization/config/schema/serialization.schema.yml b/core/modules/serialization/config/schema/serialization.schema.yml index 0fdd2da14dba6bf955f80ae7112c6b58b816005c..1d44b3dcec5fcf8095a185f2208ae4aee6e7986a 100644 --- a/core/modules/serialization/config/schema/serialization.schema.yml +++ b/core/modules/serialization/config/schema/serialization.schema.yml @@ -5,3 +5,6 @@ serialization.settings: bc_primitives_as_strings: type: boolean label: 'Whether to retain pre Drupal 8.3 behavior of serializing all primitive items as strings.' + bc_timestamp_normalizer_unix: + type: boolean + label: 'Whether the pre Drupal 8.4 behavior of returning Unix timestamps instead of RFC3339 timestamps for TimestampItem fields is enabled or not.' diff --git a/core/modules/serialization/serialization.install b/core/modules/serialization/serialization.install index 6620b446efc7626a912acd389d5d01d7a92de318..afc3311b4ce633b767bccafd04dcdb13f4999354 100644 --- a/core/modules/serialization/serialization.install +++ b/core/modules/serialization/serialization.install @@ -46,3 +46,12 @@ function serialization_update_8302() { 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, read the change record to learn how to enable the BC mode.'); } + +/** + * Enable BC for timestamp formatting: continue to return UNIX timestamps. + */ +function serialization_update_8401() { + $config_factory = \Drupal::configFactory(); + $serialization_settings = $config_factory->getEditable('serialization.settings'); + $serialization_settings->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE); +} diff --git a/core/modules/serialization/serialization.services.yml b/core/modules/serialization/serialization.services.yml index 3607182ee2590603da9ddfc5f4ce17e1dada2e2a..dca60947870859388228ad50dd2049ba43b6b33f 100644 --- a/core/modules/serialization/serialization.services.yml +++ b/core/modules/serialization/serialization.services.yml @@ -52,6 +52,12 @@ services: # Priority must be higher than serialization.normalizer.field but less # than hal field normalizer. - { name: normalizer, priority: 9 } + serializer.normalizer.timestamp_item: + class: Drupal\serialization\Normalizer\TimestampItemNormalizer + tags: + # Priority must be higher than serializer.normalizer.field_item and lower + # than hal normalizers. + - { name: normalizer, priority: 8, bc: bc_timestamp_normalizer_unix, bc_config_name: 'serialization.settings' } serializer.normalizer.password_field_item: class: Drupal\serialization\Normalizer\NullNormalizer arguments: ['Drupal\Core\Field\Plugin\Field\FieldType\PasswordItem'] diff --git a/core/modules/serialization/src/EventSubscriber/BcConfigSubscriber.php b/core/modules/serialization/src/EventSubscriber/BcConfigSubscriber.php index 79d3a905b07b9c09e719701b28f9389cf1aff3fc..4cb794ffd2bc59ef516013b91832dbf0caf2e3da 100644 --- a/core/modules/serialization/src/EventSubscriber/BcConfigSubscriber.php +++ b/core/modules/serialization/src/EventSubscriber/BcConfigSubscriber.php @@ -46,7 +46,7 @@ public function onConfigSave(ConfigCrudEvent $event) { $saved_config = $event->getConfig(); if ($saved_config->getName() === 'serialization.settings') { - if ($event->isChanged('bc_primitives_as_strings')) { + if ($event->isChanged('bc_primitives_as_strings') || $event->isChanged('bc_timestamp_normalizer_unix')) { $this->kernel->invalidateContainer(); } } diff --git a/core/modules/serialization/src/Normalizer/EntityNormalizer.php b/core/modules/serialization/src/Normalizer/EntityNormalizer.php index 84baec94be87fbfdb16780ee33f34244ba0e9613..4103d1dd7a5038fccc119fd7b644c28e5318e761 100644 --- a/core/modules/serialization/src/Normalizer/EntityNormalizer.php +++ b/core/modules/serialization/src/Normalizer/EntityNormalizer.php @@ -40,13 +40,20 @@ public function denormalize($data, $class, $format = NULL, array $context = []) // The bundle property will be required to denormalize a bundleable // fieldable entity. - if ($entity_type_definition->hasKey('bundle') && $entity_type_definition->isSubclassOf(FieldableEntityInterface::class)) { - // Get an array containing the bundle only. This also remove the bundle - // key from the $data array. - $bundle_data = $this->extractBundleData($data, $entity_type_definition); + if ($entity_type_definition->isSubclassOf(FieldableEntityInterface::class)) { + // Extract bundle data to pass into entity creation if the entity type uses + // bundles. + if ($entity_type_definition->hasKey('bundle')) { + // Get an array containing the bundle only. This also remove the bundle + // key from the $data array. + $create_params = $this->extractBundleData($data, $entity_type_definition); + } + else { + $create_params = []; + } // Create the entity from bundle data only, then apply field values after. - $entity = $this->entityManager->getStorage($entity_type_id)->create($bundle_data); + $entity = $this->entityManager->getStorage($entity_type_id)->create($create_params); $this->denormalizeFieldData($data, $entity, $format, $context); } diff --git a/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php b/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..78f6030e57106772632a5fc7f5eef0ffefa4bdcc --- /dev/null +++ b/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php @@ -0,0 +1,87 @@ + 'U', + 'ISO 8601' => \DateTime::ISO8601, + 'RFC 3339' => \DateTime::RFC3339, + ]; + + /** + * Processes normalized timestamp values to add a formatted date and format. + * + * @param array $normalized + * The normalized field data to process. + * @return array + * The processed data. + */ + protected function processNormalizedValues(array $normalized) { + // Use a RFC 3339 timestamp with the time zone set to UTC to replace the + // timestamp value. + $date = new \DateTime(); + $date->setTimestamp($normalized['value']); + $date->setTimezone(new \DateTimeZone('UTC')); + $normalized['value'] = $date->format(\DateTime::RFC3339); + // 'format' is not a property on TimestampItem fields. This is present to + // assist consumers of this data. + $normalized['format'] = \DateTime::RFC3339; + + return $normalized; + } + + /** + * {@inheritdoc} + */ + protected function constructValue($data, $context) { + // Loop through the allowed formats and create a TimestampItem from the + // input data if it matches the defined pattern. Since the formats are + // unambiguous (i.e., they reference an absolute time with a defined time + // zone), only one will ever match. + $timezone = new \DateTimeZone('UTC'); + + // First check for a provided format. + if (!empty($data['format']) && in_array($data['format'], $this->allowedFormats)) { + $date = \DateTime::createFromFormat($data['format'], $data['value'], $timezone); + return ['value' => $date->getTimestamp()]; + } + // Otherwise, loop through formats. + else { + foreach ($this->allowedFormats as $format) { + if (($date = \DateTime::createFromFormat($format, $data['value'], $timezone)) !== FALSE) { + return ['value' => $date->getTimestamp()]; + } + } + } + + $format_strings = []; + + foreach ($this->allowedFormats as $label => $format) { + $format_strings[] = "\"$format\" ($label)"; + } + + $formats = implode(', ', $format_strings); + throw new UnexpectedValueException(sprintf('The specified date "%s" is not in an accepted format: %s.', $data['value'], $formats)); + } + +} diff --git a/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php b/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php new file mode 100644 index 0000000000000000000000000000000000000000..704b22f784659e3236b24039f0f691ae542b6f6b --- /dev/null +++ b/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php @@ -0,0 +1,42 @@ +processNormalizedValues($data); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + if (empty($data['value'])) { + throw new InvalidArgumentException('No "value" attribute present'); + } + + return parent::denormalize($data, $class, $format, $context); + } + +} diff --git a/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php b/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php index 71fa6b3ec584d83f3c46b67de1bc15aa961c44bd..ebd4e0331789370572cee7a9403033e26aa19926 100644 --- a/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php +++ b/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php @@ -4,6 +4,7 @@ use Drupal\Component\Utility\SafeMarkup; use Drupal\entity_test\Entity\EntityTestMulRev; +use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; /** * Tests that entities can be serialized to supported core formats. @@ -12,6 +13,8 @@ */ class EntitySerializationTest extends NormalizerTestBase { + use BcTimestampNormalizerUnixTestTrait; + /** * Modules to install. * @@ -106,7 +109,7 @@ public function testNormalize() { ['value' => 'entity_test_mulrev'], ], 'created' => [ - ['value' => $this->entity->created->value], + $this->formatExpectedTimestampItemValues($this->entity->created->value), ], 'user_id' => [ [ @@ -182,13 +185,15 @@ public function testSerialize() { // Generate the expected xml in a way that allows changes to entity property // order. + $expected_created = $this->formatExpectedTimestampItemValues($this->entity->created->value); + $expected = [ 'id' => '' . $this->entity->id() . '', 'uuid' => '' . $this->entity->uuid() . '', 'langcode' => 'en', 'name' => '' . $this->values['name'] . '', 'type' => 'entity_test_mulrev', - 'created' => '' . $this->entity->created->value . '', + 'created' => '' . $expected_created['value'] . '' . $expected_created['format'] . '', 'user_id' => '' . $this->user->id() . '' . $this->user->getEntityTypeId() . '' . $this->user->uuid() . '' . $this->user->url() . '', 'revision_id' => '' . $this->entity->getRevisionId() . '', 'default_langcode' => '1', diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/EntityNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/EntityNormalizerTest.php index bbddbdd4844d8834efcb5d40b7233eb56b580c23..689d654bd9e9d4e82ec52f72259c25a11417b5dc 100644 --- a/core/modules/serialization/tests/src/Unit/Normalizer/EntityNormalizerTest.php +++ b/core/modules/serialization/tests/src/Unit/Normalizer/EntityNormalizerTest.php @@ -302,6 +302,10 @@ public function testDenormalizeWithNoBundle() { ]; $entity_type = $this->getMock('Drupal\Core\Entity\EntityTypeInterface'); + $entity_type->expects($this->once()) + ->method('isSubClassOf') + ->with(FieldableEntityInterface::class) + ->willReturn(TRUE); $entity_type->expects($this->once()) ->method('hasKey') ->with('bundle') @@ -314,6 +318,76 @@ public function testDenormalizeWithNoBundle() { ->with('test') ->will($this->returnValue($entity_type)); + $key_1 = $this->getMock(FieldItemListInterface::class); + $key_2 = $this->getMock(FieldItemListInterface::class); + + $entity = $this->getMock(FieldableEntityInterface::class); + $entity->expects($this->at(0)) + ->method('get') + ->with('key_1') + ->willReturn($key_1); + $entity->expects($this->at(1)) + ->method('get') + ->with('key_2') + ->willReturn($key_2); + + $storage = $this->getMock('Drupal\Core\Entity\EntityStorageInterface'); + $storage->expects($this->once()) + ->method('create') + ->with([]) + ->will($this->returnValue($entity)); + + $this->entityManager->expects($this->once()) + ->method('getStorage') + ->with('test') + ->will($this->returnValue($storage)); + + $this->entityManager->expects($this->never()) + ->method('getBaseFieldDefinitions'); + + // Setup expectations for the serializer. This will be called for each field + // item. + $serializer = $this->getMockBuilder('Symfony\Component\Serializer\Serializer') + ->disableOriginalConstructor() + ->setMethods(['denormalize']) + ->getMock(); + $serializer->expects($this->at(0)) + ->method('denormalize') + ->with('value_1', get_class($key_1), NULL, ['target_instance' => $key_1, 'entity_type' => 'test']); + $serializer->expects($this->at(1)) + ->method('denormalize') + ->with('value_2', get_class($key_2), NULL, ['target_instance' => $key_2, 'entity_type' => 'test']); + + $this->entityNormalizer->setSerializer($serializer); + + $this->assertNotNull($this->entityNormalizer->denormalize($test_data, 'Drupal\Core\Entity\ContentEntityBase', NULL, ['entity_type' => 'test'])); + } + + /** + * Tests the denormalize method with no bundle defined. + * + * @covers ::denormalize + */ + public function testDenormalizeWithNoFieldableEntityType() { + $test_data = [ + 'key_1' => 'value_1', + 'key_2' => 'value_2', + ]; + + $entity_type = $this->getMock('Drupal\Core\Entity\EntityTypeInterface'); + $entity_type->expects($this->once()) + ->method('isSubClassOf') + ->with(FieldableEntityInterface::class) + ->willReturn(FALSE); + + $entity_type->expects($this->never()) + ->method('getKey'); + + $this->entityManager->expects($this->once()) + ->method('getDefinition') + ->with('test') + ->will($this->returnValue($entity_type)); + $storage = $this->getMock('Drupal\Core\Entity\EntityStorageInterface'); $storage->expects($this->once()) ->method('create') diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/TimestampItemNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/TimestampItemNormalizerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fd1fc9ce9f3b773df2efe384864202ed1af7fbfd --- /dev/null +++ b/core/modules/serialization/tests/src/Unit/Normalizer/TimestampItemNormalizerTest.php @@ -0,0 +1,156 @@ +normalizer = new TimestampItemNormalizer(); + } + + /** + * @covers ::supportsNormalization + */ + public function testSupportsNormalization() { + $timestamp_item = $this->createTimestampItemProphecy(); + $this->assertTrue($this->normalizer->supportsNormalization($timestamp_item->reveal())); + + $entity_ref_item = $this->prophesize(EntityReferenceItem::class); + $this->assertFalse($this->normalizer->supportsNormalization($entity_ref_item->reveal())); + } + + /** + * @covers ::supportsDenormalization + */ + public function testSupportsDenormalization() { + $timestamp_item = $this->createTimestampItemProphecy(); + $this->assertTrue($this->normalizer->supportsDenormalization($timestamp_item->reveal(), TimestampItem::class)); + + // CreatedItem extends regular TimestampItem. + $timestamp_item = $this->prophesize(CreatedItem::class); + $this->assertTrue($this->normalizer->supportsDenormalization($timestamp_item->reveal(), TimestampItem::class)); + + $entity_ref_item = $this->prophesize(EntityReferenceItem::class); + $this->assertFalse($this->normalizer->supportsNormalization($entity_ref_item->reveal(), TimestampItem::class)); + } + + /** + * Tests the normalize function. + * + * @covers ::normalize + */ + public function testNormalize() { + $expected = ['value' => '2016-11-06T09:02:00+00:00', 'format' => \DateTime::RFC3339]; + + $timestamp_item = $this->createTimestampItemProphecy(); + $timestamp_item->getIterator() + ->willReturn(new \ArrayIterator(['value' => 1478422920])); + + $serializer = new Serializer(); + $this->normalizer->setSerializer($serializer); + + $normalized = $this->normalizer->normalize($timestamp_item->reveal()); + $this->assertSame($expected, $normalized); + } + + /** + * Tests the denormalize function with good data. + * + * @covers ::denormalize + * @dataProvider providerTestDenormalizeValidFormats + */ + public function testDenormalizeValidFormats($value, $expected) { + $normalized = ['value' => $value]; + + $timestamp_item = $this->createTimestampItemProphecy(); + // The field item should be set with the expected timestamp. + $timestamp_item->setValue(['value' => $expected]) + ->shouldBeCalled(); + + $context = ['target_instance' => $timestamp_item->reveal()]; + + $denormalized = $this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context); + $this->assertTrue($denormalized instanceof TimestampItem); + } + + /** + * Data provider for testDenormalizeValidFormats. + * + * @return array + */ + public function providerTestDenormalizeValidFormats() { + $expected_stamp = 1478422920; + + $data = []; + + $data['U'] = [$expected_stamp, $expected_stamp]; + $data['RFC3339'] = ['2016-11-06T09:02:00+00:00', $expected_stamp]; + $data['RFC3339 +0100'] = ['2016-11-06T09:02:00+01:00', $expected_stamp - 1 * 3600]; + $data['RFC3339 -0600'] = ['2016-11-06T09:02:00-06:00', $expected_stamp + 6 * 3600]; + + $data['ISO8601'] = ['2016-11-06T09:02:00+0000', $expected_stamp]; + $data['ISO8601 +0100'] = ['2016-11-06T09:02:00+0100', $expected_stamp - 1 * 3600]; + $data['ISO8601 -0600'] = ['2016-11-06T09:02:00-0600', $expected_stamp + 6 * 3600]; + + return $data; + } + + /** + * Tests the denormalize function with bad data. + * + * @covers ::denormalize + */ + public function testDenormalizeException() { + $this->setExpectedException(UnexpectedValueException::class, 'The specified date "2016/11/06 09:02am GMT" is not in an accepted format: "U" (UNIX timestamp), "Y-m-d\TH:i:sO" (ISO 8601), "Y-m-d\TH:i:sP" (RFC 3339).'); + + $context = ['target_instance' => $this->createTimestampItemProphecy()->reveal()]; + + $normalized = ['value' => '2016/11/06 09:02am GMT']; + $this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context); + } + + /** + * Creates a TimestampItem prophecy. + * + * @return \Prophecy\Prophecy\ObjectProphecy|\Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem + */ + protected function createTimestampItemProphecy() { + $timestamp_item = $this->prophesize(TimestampItem::class); + $timestamp_item->getParent() + ->willReturn(TRUE); + + return $timestamp_item; + } + +}