From c8dc57eedea3fd34de067098ca2ee36e6c459245 Mon Sep 17 00:00:00 2001 From: Francesco Placella Date: Sat, 16 Feb 2019 00:13:43 +0100 Subject: [PATCH] Issue #2926508 by Wim Leers, mpdonadio, joelstein, tacituseu, jhedstrom, effulgentsia, tedbow, mradcliffe, borisson_, dawehner, larowlan: Add DateTimeNormalizer+TimestampNormalizer, deprecate TimestampItemNormalizer: @DataType-level normalizers are reusable by JSON:API --- .../TypedData/Plugin/DataType/Timestamp.php | 2 +- .../EntityTest/EntityTestDateonlyTest.php | 4 +- .../EntityTest/EntityTestDatetimeTest.php | 44 ++- .../EntityTest/EntityTestDateRangeTest.php | 164 ++++++++++ .../Normalizer/TimestampItemNormalizer.php | 26 +- .../BcTimestampNormalizerUnixTestTrait.php | 2 + .../serialization/serialization.services.yml | 12 + .../Normalizer/DateTimeIso8601Normalizer.php | 100 +++++++ .../src/Normalizer/DateTimeNormalizer.php | 121 ++++++++ .../TimeStampItemNormalizerTrait.php | 4 + .../Normalizer/TimestampItemNormalizer.php | 29 +- .../src/Normalizer/TimestampNormalizer.php | 47 +++ .../DateTimeIso8601NormalizerTest.php | 283 ++++++++++++++++++ .../Normalizer/DateTimeNormalizerTest.php | 192 ++++++++++++ ...StampItemNormalizerTraitDeprecatedTest.php | 25 ++ ...ItemNormalizerTraitDeprecatedTestClass.php | 15 + .../TimestampItemNormalizerTest.php | 112 ++++--- .../Normalizer/TimestampNormalizerTest.php | 147 +++++++++ 18 files changed, 1244 insertions(+), 85 deletions(-) create mode 100644 core/modules/datetime_range/tests/src/Functional/EntityResource/EntityTest/EntityTestDateRangeTest.php create mode 100644 core/modules/serialization/src/Normalizer/DateTimeIso8601Normalizer.php create mode 100644 core/modules/serialization/src/Normalizer/DateTimeNormalizer.php create mode 100644 core/modules/serialization/src/Normalizer/TimestampNormalizer.php create mode 100644 core/modules/serialization/tests/src/Unit/Normalizer/DateTimeIso8601NormalizerTest.php create mode 100644 core/modules/serialization/tests/src/Unit/Normalizer/DateTimeNormalizerTest.php create mode 100644 core/modules/serialization/tests/src/Unit/Normalizer/TimeStampItemNormalizerTraitDeprecatedTest.php create mode 100644 core/modules/serialization/tests/src/Unit/Normalizer/TimeStampItemNormalizerTraitDeprecatedTestClass.php create mode 100644 core/modules/serialization/tests/src/Unit/Normalizer/TimestampNormalizerTest.php diff --git a/core/lib/Drupal/Core/TypedData/Plugin/DataType/Timestamp.php b/core/lib/Drupal/Core/TypedData/Plugin/DataType/Timestamp.php index 8c72c859cee..60cd9b423b6 100644 --- a/core/lib/Drupal/Core/TypedData/Plugin/DataType/Timestamp.php +++ b/core/lib/Drupal/Core/TypedData/Plugin/DataType/Timestamp.php @@ -26,7 +26,7 @@ class Timestamp extends IntegerData implements DateTimeInterface { * {@inheritdoc} */ public function getDateTime() { - if ($this->value) { + if (isset($this->value)) { return DrupalDateTime::createFromTimestamp($this->value); } } diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php index 0fb404d2b97..e8c71ef0ccf 100644 --- a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php @@ -136,7 +136,7 @@ class EntityTestDateonlyTest extends EntityTestResourceTestBase { $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); $response = $this->request($method, $url, $request_options); - $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' is invalid for the format 'Y-m-d'\n"; + $message = "The specified date \"$value\" is not in an accepted format: \"Y-m-d\" (date-only)."; $this->assertResourceErrorResponse(422, $message, $response); // DX: 422 when value is not a valid date. @@ -146,7 +146,7 @@ class EntityTestDateonlyTest extends EntityTestResourceTestBase { $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); $response = $this->request($method, $url, $request_options); - $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' did not parse properly for the format 'Y-m-d'\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $message = "The specified date \"$value\" is not in an accepted format: \"Y-m-d\" (date-only)."; $this->assertResourceErrorResponse(422, $message, $response); } } diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php index cdd4d605d0d..f5efce53f38 100644 --- a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php @@ -90,7 +90,7 @@ class EntityTestDatetimeTest extends EntityTestResourceTestBase { return parent::getExpectedNormalizedEntity() + [ static::$fieldName => [ [ - 'value' => $this->entity->get(static::$fieldName)->value, + 'value' => '2017-03-02T07:02:00+11:00', ], ], ]; @@ -103,6 +103,24 @@ class EntityTestDatetimeTest extends EntityTestResourceTestBase { return parent::getNormalizedPostEntity() + [ static::$fieldName => [ [ + 'value' => static::$dateString . '+00:00', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPatchEntity() { + return parent::getNormalizedPostEntity() + [ + static::$fieldName => [ + [ + // Omitting the timezone is allowed, this should result in the site's + // timezone being used automatically. This does not make sense, but + // it's how it functioned in the past, so we explicitly test this to + // guarantee backward compatibility. ::getNormalizedPostEntity() tests + // the recommended case, this tests backward compatibility. 'value' => static::$dateString, ], ], @@ -136,7 +154,7 @@ class EntityTestDatetimeTest extends EntityTestResourceTestBase { $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); $response = $this->request($method, $url, $request_options); - $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' is invalid for the format 'Y-m-d\\TH:i:s'\n"; + $message = "The specified date \"$value\" is not in an accepted format: \"Y-m-d\\TH:i:sP\" (RFC 3339), \"Y-m-d\\TH:i:sO\" (ISO 8601), \"Y-m-d\\TH:i:s\" (backward compatibility — deprecated)."; $this->assertResourceErrorResponse(422, $message, $response); // DX: 422 when date format is incorrect. @@ -146,9 +164,29 @@ class EntityTestDatetimeTest extends EntityTestResourceTestBase { $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); $response = $this->request($method, $url, $request_options); - $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' did not parse properly for the format 'Y-m-d\\TH:i:s'\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $message = "The specified date \"$value\" is not in an accepted format: \"Y-m-d\\TH:i:sP\" (RFC 3339), \"Y-m-d\\TH:i:sO\" (ISO 8601), \"Y-m-d\\TH:i:s\" (backward compatibility — deprecated)."; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when date value is invalid. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-13-55T20:02:00+00:00'; + $normalization[static::$fieldName][0]['value'] = $value; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "The specified date \"$value\" is not in an accepted format: \"Y-m-d\\TH:i:sP\" (RFC 3339), \"Y-m-d\\TH:i:sO\" (ISO 8601), \"Y-m-d\\TH:i:s\" (backward compatibility — deprecated)."; $this->assertResourceErrorResponse(422, $message, $response); } } + /** + * {@inheritdoc} + * + * @group legacy + * @expectedDeprecation The provided datetime string format (Y-m-d\TH:i:s) is deprecated and will be removed before Drupal 9.0.0. Use the RFC3339 format instead (Y-m-d\TH:i:sP). + */ + public function testPatch() { + return parent::testPatch(); + } + } diff --git a/core/modules/datetime_range/tests/src/Functional/EntityResource/EntityTest/EntityTestDateRangeTest.php b/core/modules/datetime_range/tests/src/Functional/EntityResource/EntityTest/EntityTestDateRangeTest.php new file mode 100644 index 00000000000..e669a223710 --- /dev/null +++ b/core/modules/datetime_range/tests/src/Functional/EntityResource/EntityTest/EntityTestDateRangeTest.php @@ -0,0 +1,164 @@ + static::$fieldName, + 'type' => 'daterange', + 'entity_type' => static::$entityTypeId, + 'settings' => ['datetime_type' => DateRangeItem::DATETIME_TYPE_ALLDAY], + ])->save(); + + FieldConfig::create([ + 'field_name' => static::$fieldName, + 'entity_type' => static::$entityTypeId, + 'bundle' => $this->entity->bundle(), + ])->save(); + + // Reload entity so that it has the new field. + $this->entity = $this->entityStorage->load($this->entity->id()); + $this->entity->set(static::$fieldName, [ + 'value' => static::$dateString, + 'end_value' => static::$dateString, + ]); + $this->entity->save(); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $entity_test = EntityTest::create([ + 'name' => 'Llama', + 'type' => static::$entityTypeId, + ]); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return parent::getExpectedNormalizedEntity() + [ + static::$fieldName => [ + [ + 'value' => '2017-03-02T07:02:00+11:00', + 'end_value' => '2017-03-02T07:02:00+11:00', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + static::$fieldName => [ + [ + 'value' => '2017-03-01T20:02:00+00:00', + 'end_value' => '2017-03-01T20:02:00+00:00', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + parent::assertNormalizationEdgeCases($method, $url, $request_options); + + if ($this->entity->getEntityType()->hasKey('bundle')) { + $fieldName = static::$fieldName; + + // DX: 422 when 'value' data type is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $normalization[static::$fieldName][0]['value'] = [ + '2017', '03', '01', '21', '53', '00', + ]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when 'end_value' is not specified. + $normalization = $this->getNormalizedPostEntity(); + unset($normalization[static::$fieldName][0]['end_value']); + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0.end_value: This value should not be null.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when 'end_value' data type is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $normalization[static::$fieldName][0]['end_value'] = [ + '2017', '03', '01', '21', '53', '00', + ]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0.end_value: This value should be of the correct primitive type.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when end date value is invalid. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-13-55T20:02:00+00:00'; + $normalization[static::$fieldName][0]['end_value'] = $value; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "The specified date \"$value\" is not in an accepted format: \"Y-m-d\\TH:i:sP\" (RFC 3339), \"Y-m-d\\TH:i:sO\" (ISO 8601), \"Y-m-d\\TH:i:s\" (backward compatibility — deprecated)."; + $this->assertResourceErrorResponse(422, $message, $response); + + // @todo Expand in https://www.drupal.org/project/drupal/issues/2847041. + } + } + +} diff --git a/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php b/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php index 6c47e183212..afb905fea9c 100644 --- a/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php +++ b/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php @@ -4,15 +4,18 @@ namespace Drupal\hal\Normalizer; use Drupal\Core\Field\FieldItemInterface; use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem; -use Drupal\serialization\Normalizer\TimeStampItemNormalizerTrait; +use Drupal\Core\TypedData\Plugin\DataType\Timestamp; /** * Converts values for TimestampItem to and from common formats for hal. + * + * Overrides FieldItemNormalizer to + * - during normalization, add the 'format' key to assist consumers + * - during denormalization, use + * \Drupal\serialization\Normalizer\TimestampNormalizer */ class TimestampItemNormalizer extends FieldItemNormalizer { - use TimeStampItemNormalizerTrait; - /** * {@inheritdoc} */ @@ -22,8 +25,21 @@ class TimestampItemNormalizer extends FieldItemNormalizer { * {@inheritdoc} */ protected function normalizedFieldValues(FieldItemInterface $field_item, $format, array $context) { - $normalized = parent::normalizedFieldValues($field_item, $format, $context); - return $this->processNormalizedValues($normalized); + return parent::normalizedFieldValues($field_item, $format, $context) + [ + // 'format' is not a property on Timestamp objects. This is present to + // assist consumers of this data. + 'format' => \DateTime::RFC3339, + ]; + } + + /** + * {@inheritdoc} + */ + protected function constructValue($data, $context) { + if (!empty($data['format'])) { + $context['datetime_allowed_formats'] = [$data['format']]; + } + return ['value' => $this->serializer->denormalize($data['value'], Timestamp::class, NULL, $context)]; } } diff --git a/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php b/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php index a275f646f74..506db71e1c6 100644 --- a/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php +++ b/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php @@ -31,6 +31,8 @@ trait BcTimestampNormalizerUnixTestTrait { // \Drupal\serialization\Normalizer\TimestampItemNormalizer will produce. $date = new \DateTime(); $date->setTimestamp($timestamp); + // Per \Drupal\Core\TypedData\Plugin\DataType\Timestamp::getDateTime(), they + // default to string representations in the UTC timezone. $date->setTimezone(new \DateTimeZone('UTC')); // Format is also added to the expected return values. diff --git a/core/modules/serialization/serialization.services.yml b/core/modules/serialization/serialization.services.yml index 324a826e4f5..bc316200ad4 100644 --- a/core/modules/serialization/serialization.services.yml +++ b/core/modules/serialization/serialization.services.yml @@ -58,6 +58,18 @@ services: # 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.timestamp: + class: Drupal\serialization\Normalizer\TimestampNormalizer + arguments: ['@config.factory'] + tags: + # Priority must be higher than serializer.normalizer.primitive_data. + - { name: normalizer, priority: 20, bc: bc_timestamp_normalizer_unix, bc_config_name: 'serialization.settings' } + serializer.normalizer.datetimeiso8601: + class: \Drupal\serialization\Normalizer\DateTimeIso8601Normalizer + arguments: ['@config.factory'] + tags: + # Priority must be higher than serializer.normalizer.primitive_data. + - { name: normalizer, priority: 20 } 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/Normalizer/DateTimeIso8601Normalizer.php b/core/modules/serialization/src/Normalizer/DateTimeIso8601Normalizer.php new file mode 100644 index 00000000000..fc6cf9f33ad --- /dev/null +++ b/core/modules/serialization/src/Normalizer/DateTimeIso8601Normalizer.php @@ -0,0 +1,100 @@ + \DateTime::RFC3339, + 'ISO 8601' => \DateTime::ISO8601, + // @todo Remove this in https://www.drupal.org/project/drupal/issues/2958416. + // RFC3339 only covers combined date and time representations. For date-only + // representations, we need to use ISO 8601. There isn't a constant on the + // \DateTime class that we can use, so we have to hardcode the format. + // @see https://en.wikipedia.org/wiki/ISO_8601#Calendar_dates + // @see \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::DATE_STORAGE_FORMAT + 'date-only' => 'Y-m-d', + ]; + + /** + * {@inheritdoc} + */ + protected $supportedInterfaceOrClass = DateTimeIso8601::class; + + /** + * {@inheritdoc} + */ + public function normalize($datetime, $format = NULL, array $context = []) { + assert($datetime instanceof DateTimeIso8601); + $field_item = $datetime->getParent(); + // @todo Remove this in https://www.drupal.org/project/drupal/issues/2958416. + if ($field_item instanceof DateTimeItem && $field_item->getFieldDefinition()->getFieldStorageDefinition()->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { + $drupal_date_time = $datetime->getDateTime(); + if ($drupal_date_time === NULL) { + return $drupal_date_time; + } + return $drupal_date_time->format($this->allowedFormats['date-only']); + } + return parent::normalize($datetime, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + // @todo Move the date-only handling out of here in https://www.drupal.org/project/drupal/issues/2958416. + $field_definition = isset($context['target_instance']) + ? $context['target_instance']->getFieldDefinition() + : (isset($context['field_definition']) ? $context['field_definition'] : NULL); + if ($field_definition === NULL) { + throw new InvalidArgumentException('$context[\'target_instance\'] or $context[\'field_definition\'] must be set to denormalize with the DateTimeIso8601Normalizer'); + } + + $datetime_type = $field_definition->getSetting('datetime_type'); + $is_date_only = $datetime_type === DateTimeItem::DATETIME_TYPE_DATE; + + if ($is_date_only) { + $context['datetime_allowed_formats'] = array_intersect_key($this->allowedFormats, ['date-only' => TRUE]); + $datetime = parent::denormalize($data, $class, $format, $context); + if (!$datetime instanceof \DateTime) { + return $datetime; + } + return $datetime->format(DateTimeItemInterface::DATE_STORAGE_FORMAT); + } + + $context['datetime_allowed_formats'] = array_diff_key($this->allowedFormats, ['date-only' => TRUE]); + try { + $datetime = parent::denormalize($data, $class, $format, $context); + } + catch (\UnexpectedValueException $e) { + // If denormalization didn't work using any of the actively supported + // formats, try again with the BC format too. Explicitly label it as + // being deprecated and trigger a deprecation error. + $using_deprecated_format = TRUE; + $context['datetime_allowed_formats']['backward compatibility — deprecated'] = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; + $datetime = parent::denormalize($data, $class, $format, $context); + } + if (!$datetime instanceof \DateTime) { + return $datetime; + } + if (isset($using_deprecated_format)) { + @trigger_error('The provided datetime string format (Y-m-d\\TH:i:s) is deprecated and will be removed before Drupal 9.0.0. Use the RFC3339 format instead (Y-m-d\\TH:i:sP).', E_USER_DEPRECATED); + } + $datetime->setTimezone(new \DateTimeZone(DateTimeItemInterface::STORAGE_TIMEZONE)); + return $datetime->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT); + } + +} diff --git a/core/modules/serialization/src/Normalizer/DateTimeNormalizer.php b/core/modules/serialization/src/Normalizer/DateTimeNormalizer.php new file mode 100644 index 00000000000..209d15201b5 --- /dev/null +++ b/core/modules/serialization/src/Normalizer/DateTimeNormalizer.php @@ -0,0 +1,121 @@ + \DateTime::RFC3339, + 'ISO 8601' => \DateTime::ISO8601, + ]; + + /** + * {@inheritdoc} + */ + protected $supportedInterfaceOrClass = DateTimeInterface::class; + + /** + * The system's date configuration. + * + * @var \Drupal\Core\Config\ImmutableConfig + */ + protected $systemDateConfig; + + /** + * Constructs a new DateTimeNormalizer instance. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * A config factory for retrieving required config objects. + */ + public function __construct(ConfigFactoryInterface $config_factory) { + $this->systemDateConfig = $config_factory->get('system.date'); + } + + /** + * {@inheritdoc} + */ + public function normalize($datetime, $format = NULL, array $context = []) { + assert($datetime instanceof DateTimeInterface); + $drupal_date_time = $datetime->getDateTime(); + if ($drupal_date_time === NULL) { + return $drupal_date_time; + } + return $drupal_date_time + // Set an explicit timezone. Otherwise, timestamps may end up being + // normalized using the user's preferred timezone. Which would result in + // many variations and complex caching. + // @see \Drupal\Core\Datetime\DrupalDateTime::prepareTimezone() + // @see drupal_get_user_timezone() + ->setTimezone($this->getNormalizationTimezone()) + ->format(\DateTime::RFC3339); + } + + /** + * Gets the timezone to be used during normalization. + * + * @see ::normalize + * + * @returns \DateTimeZone + * The timezone to use. + */ + protected function getNormalizationTimezone() { + $default_site_timezone = $this->systemDateConfig->get('timezone.default'); + return new \DateTimeZone($default_site_timezone); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + // This only knows how to denormalize datetime strings and timestamps. If + // something else is received, let validation constraints handle this. + if (!is_string($data) && !is_numeric($data)) { + return $data; + } + + // Loop through the allowed formats and create a \DateTime 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. + $allowed_formats = isset($context['datetime_allowed_formats']) + ? $context['datetime_allowed_formats'] + : $this->allowedFormats; + foreach ($allowed_formats as $format) { + $date = \DateTime::createFromFormat($format, $data); + $errors = \DateTime::getLastErrors(); + if ($date !== FALSE && empty($errors['errors']) && empty($errors['warnings'])) { + return $date; + } + } + + $format_strings = []; + + foreach ($allowed_formats 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, $formats)); + } + +} diff --git a/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php b/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php index 1ad0d8e66be..50296711f3e 100644 --- a/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php +++ b/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php @@ -4,8 +4,12 @@ namespace Drupal\serialization\Normalizer; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +@trigger_error(__NAMESPACE__ . '\TimeStampItemNormalizerTrait is deprecated in Drupal 8.7.0 and will be removed in Drupal 9.0.0. Use \Drupal\serialization\Normalizer\TimestampNormalizer instead.', E_USER_DEPRECATED); + /** * A trait for TimestampItem normalization functionality. + * + * @deprecated in 8.7.0, use \Drupal\serialization\Normalizer\TimestampNormalizer instead. */ trait TimeStampItemNormalizerTrait { diff --git a/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php b/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php index 484b8347ad9..1669e6a7803 100644 --- a/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php +++ b/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php @@ -3,15 +3,19 @@ namespace Drupal\serialization\Normalizer; use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Drupal\Core\TypedData\Plugin\DataType\Timestamp; /** * Converts values for TimestampItem to and from common formats. + * + * Overrides FieldItemNormalizer to use \Drupal\serialization\Normalizer\TimestampNormalizer + * + * Overrides FieldItemNormalizer to + * - during normalization, add the 'format' key to assist consumers + * - during denormalization, use \Drupal\serialization\Normalizer\TimestampNormalizer */ class TimestampItemNormalizer extends FieldItemNormalizer { - use TimeStampItemNormalizerTrait; - /** * {@inheritdoc} */ @@ -20,21 +24,22 @@ class TimestampItemNormalizer extends FieldItemNormalizer { /** * {@inheritdoc} */ - public function normalize($field_item, $format = NULL, array $context = []) { - $data = parent::normalize($field_item, $format, $context); - - return $this->processNormalizedValues($data); + public function normalize($object, $format = NULL, array $context = []) { + return parent::normalize($object, $format, $context) + [ + // 'format' is not a property on Timestamp objects. This is present to + // assist consumers of this data. + 'format' => \DateTime::RFC3339, + ]; } /** * {@inheritdoc} */ - public function denormalize($data, $class, $format = NULL, array $context = []) { - if (empty($data['value'])) { - throw new InvalidArgumentException('No "value" attribute present'); + protected function constructValue($data, $context) { + if (!empty($data['format'])) { + $context['datetime_allowed_formats'] = [$data['format']]; } - - return parent::denormalize($data, $class, $format, $context); + return ['value' => $this->serializer->denormalize($data['value'], Timestamp::class, NULL, $context)]; } } diff --git a/core/modules/serialization/src/Normalizer/TimestampNormalizer.php b/core/modules/serialization/src/Normalizer/TimestampNormalizer.php new file mode 100644 index 00000000000..9be4d6ef949 --- /dev/null +++ b/core/modules/serialization/src/Normalizer/TimestampNormalizer.php @@ -0,0 +1,47 @@ + 'U', + 'ISO 8601' => \DateTime::ISO8601, + 'RFC 3339' => \DateTime::RFC3339, + ]; + + /** + * {@inheritdoc} + */ + protected $supportedInterfaceOrClass = Timestamp::class; + + /** + * {@inheritdoc} + */ + protected function getNormalizationTimezone() { + return new \DateTimeZone('UTC'); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = []) { + $denormalized = parent::denormalize($data, $class, $format, $context); + return $denormalized->getTimestamp(); + } + +} diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/DateTimeIso8601NormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/DateTimeIso8601NormalizerTest.php new file mode 100644 index 00000000000..188d0b54cd6 --- /dev/null +++ b/core/modules/serialization/tests/src/Unit/Normalizer/DateTimeIso8601NormalizerTest.php @@ -0,0 +1,283 @@ +prophesize(ImmutableConfig::class); + $system_date_config->get('timezone.default') + ->willReturn('Australia/Sydney'); + $config_factory = $this->prophesize(ConfigFactoryInterface::class); + $config_factory->get('system.date') + ->willReturn($system_date_config->reveal()); + + $this->normalizer = new DateTimeIso8601Normalizer($config_factory->reveal()); + $this->data = $this->prophesize(DateTimeIso8601::class); + } + + /** + * @covers ::supportsNormalization + */ + public function testSupportsNormalization() { + $this->assertTrue($this->normalizer->supportsNormalization($this->data->reveal())); + + $datetime = $this->prophesize(DateTimeInterface::class); + $this->assertFalse($this->normalizer->supportsNormalization($datetime->reveal())); + + $integer = $this->prophesize(IntegerData::class); + $this->assertFalse($this->normalizer->supportsNormalization($integer->reveal())); + } + + /** + * @covers ::supportsDenormalization + */ + public function testSupportsDenormalization() { + $this->assertTrue($this->normalizer->supportsDenormalization($this->data->reveal(), DateTimeIso8601::class)); + } + + /** + * @covers ::normalize + * @dataProvider providerTestNormalize + */ + public function testNormalize($parent_field_item_class, $datetime_type, $expected_format) { + $formatted_string = $this->randomMachineName(); + + $field_item = $this->prophesize($parent_field_item_class); + if ($parent_field_item_class === DateTimeItem::class) { + $field_storage_definition = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage_definition->getSetting('datetime_type') + ->willReturn($datetime_type); + $field_definition = $this->prophesize(FieldDefinitionInterface::class); + $field_definition->getFieldStorageDefinition() + ->willReturn($field_storage_definition); + $field_item->getFieldDefinition() + ->willReturn($field_definition); + } + else { + $field_item->getFieldDefinition(Argument::any()) + ->shouldNotBeCalled(); + } + $this->data->getParent() + ->willReturn($field_item); + + $drupal_date_time = $this->prophesize(DateTimeIso8601NormalizerTestDrupalDateTime::class); + $drupal_date_time->setTimezone(new \DateTimeZone('Australia/Sydney')) + ->willReturn($drupal_date_time->reveal()); + $drupal_date_time->format($expected_format) + ->willReturn($formatted_string); + $this->data->getDateTime() + ->willReturn($drupal_date_time->reveal()); + + $normalized = $this->normalizer->normalize($this->data->reveal()); + $this->assertSame($formatted_string, $normalized); + } + + /** + * @covers ::normalize + * @dataProvider providerTestNormalize + */ + public function testNormalizeWhenNull($parent_field_item_class, $datetime_type, $expected_format) { + $field_item = $this->prophesize($parent_field_item_class); + if ($parent_field_item_class === DateTimeItem::class) { + $field_storage_definition = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage_definition->getSetting('datetime_type') + ->willReturn($datetime_type); + $field_definition = $this->prophesize(FieldDefinitionInterface::class); + $field_definition->getFieldStorageDefinition() + ->willReturn($field_storage_definition); + $field_item->getFieldDefinition() + ->willReturn($field_definition); + } + else { + $field_item->getFieldDefinition(Argument::any()) + ->shouldNotBeCalled(); + } + $this->data->getParent() + ->willReturn($field_item); + + $this->data->getDateTime() + ->willReturn(NULL); + + $normalized = $this->normalizer->normalize($this->data->reveal()); + $this->assertNull($normalized); + } + + /** + * Data provider for testNormalize. + * + * @return array + */ + public function providerTestNormalize() { + return [ + // @see \Drupal\datetime\Plugin\Field\FieldType\DateTimeItem::DATETIME_TYPE_DATE + 'datetime field, configured to store only date: must be handled by DateTimeIso8601Normalizer' => [ + DateTimeItem::class, + DateTimeItem::DATETIME_TYPE_DATE, + // This expected format call proves that normalization is handled by \Drupal\serialization\Normalizer\DateTimeIso8601Normalizer::normalize(). + 'Y-m-d', + ], + // @see \Drupal\datetime\Plugin\Field\FieldType\DateTimeItem::DATETIME_TYPE_DATETIME + 'datetime field, configured to store date and time; must be handled by the parent normalizer' => [ + DateTimeItem::class, + DateTimeItem::DATETIME_TYPE_DATETIME, + \DateTime::RFC3339, + ], + 'non-datetime field; must be handled by the parent normalizer' => [ + FieldItemBase::class, + NULL, + \DateTime::RFC3339, + ], + + ]; + } + + /** + * Tests the denormalize function with good data. + * + * @covers ::denormalize + * @dataProvider providerTestDenormalizeValidFormats + */ + public function testDenormalizeValidFormats($type, $normalized, $expected) { + $field_definition = $this->prophesize(FieldDefinitionInterface::class); + $field_definition->getSetting('datetime_type')->willReturn($type === 'date-only' ? DateTimeItem::DATETIME_TYPE_DATE : DateTimeItem::DATETIME_TYPE_DATETIME); + $denormalized = $this->normalizer->denormalize($normalized, DateTimeIso8601::class, NULL, [ + 'field_definition' => $field_definition->reveal(), + ]); + $this->assertSame($expected, $denormalized); + } + + /** + * Data provider for testDenormalizeValidFormats. + * + * @return array + */ + public function providerTestDenormalizeValidFormats() { + $data = []; + $data['just a date'] = ['date-only', '2016-11-06', '2016-11-06']; + + $data['RFC3339'] = ['date+time', '2016-11-06T09:02:00+00:00', '2016-11-06T09:02:00']; + $data['RFC3339 +0100'] = ['date+time', '2016-11-06T09:02:00+01:00', '2016-11-06T08:02:00']; + $data['RFC3339 -0600'] = ['date+time', '2016-11-06T09:02:00-06:00', '2016-11-06T15:02:00']; + + $data['ISO8601'] = ['date+time', '2016-11-06T09:02:00+0000', '2016-11-06T09:02:00']; + $data['ISO8601 +0100'] = ['date+time', '2016-11-06T09:02:00+0100', '2016-11-06T08:02:00']; + $data['ISO8601 -0600'] = ['date+time', '2016-11-06T09:02:00-0600', '2016-11-06T15:02:00']; + + return $data; + } + + /** + * Tests the denormalize function with the date+time deprecated format. + * + * @covers ::denormalize + * @group legacy + * @expectedDeprecation The provided datetime string format (Y-m-d\TH:i:s) is deprecated and will be removed before Drupal 9.0.0. Use the RFC3339 format instead (Y-m-d\TH:i:sP). + */ + public function testDenormalizeDateAndTimeDeprecatedFormat() { + $normalized = '2016-11-06T08:00:00'; + + $field_definition = $this->prophesize(FieldDefinitionInterface::class); + $field_definition->getSetting('datetime_type')->willReturn(DateTimeItem::DATETIME_TYPE_DATETIME); + $this->normalizer->denormalize($normalized, DateTimeIso8601::class, NULL, ['field_definition' => $field_definition->reveal()]); + } + + /** + * Tests the denormalize function with bad data for the date-only case. + * + * @covers ::denormalize + */ + public function testDenormalizeDateOnlyException() { + $this->setExpectedException(UnexpectedValueException::class, 'The specified date "2016/11/06" is not in an accepted format: "Y-m-d" (date-only).'); + + $normalized = '2016/11/06'; + + $field_definition = $this->prophesize(FieldDefinitionInterface::class); + $field_definition->getSetting('datetime_type')->willReturn(DateTimeItem::DATETIME_TYPE_DATE); + $this->normalizer->denormalize($normalized, DateTimeIso8601::class, NULL, ['field_definition' => $field_definition->reveal()]); + } + + /** + * Tests the denormalize function with bad data for the date+time case. + * + * @covers ::denormalize + */ + public function testDenormalizeDateAndTimeException() { + $this->setExpectedException(UnexpectedValueException::class, 'The specified date "on a rainy day" is not in an accepted format: "Y-m-d\TH:i:sP" (RFC 3339), "Y-m-d\TH:i:sO" (ISO 8601), "Y-m-d\TH:i:s" (backward compatibility — deprecated).'); + + $normalized = 'on a rainy day'; + + $field_definition = $this->prophesize(FieldDefinitionInterface::class); + $field_definition->getSetting('datetime_type')->willReturn(DateTimeItem::DATETIME_TYPE_DATETIME); + $this->normalizer->denormalize($normalized, DateTimeIso8601::class, NULL, ['field_definition' => $field_definition->reveal()]); + } + + /** + * Tests the denormalize function with incomplete serialization context. + * + * @covers ::denormalize + */ + public function testDenormalizeNoTargetInstanceOrFieldDefinitionException() { + $this->setExpectedException(InvalidArgumentException::class, '$context[\'target_instance\'] or $context[\'field_definition\'] must be set to denormalize with the DateTimeIso8601Normalizer'); + $this->normalizer->denormalize('', DateTimeIso8601::class, NULL, []); + } + +} + +/** + * Note: Prophecy does not support magic methods. By subclassing and specifying + * an explicit method, Prophecy works. + * @see https://github.com/phpspec/prophecy/issues/338 + * @see https://github.com/phpspec/prophecy/issues/34 + * @see https://github.com/phpspec/prophecy/issues/80 + */ +class DateTimeIso8601NormalizerTestDrupalDateTime extends DrupalDateTime { + + public function setTimezone(\DateTimeZone $timezone) { + parent::setTimezone($timezone); + } + +} diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/DateTimeNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/DateTimeNormalizerTest.php new file mode 100644 index 00000000000..8914186e244 --- /dev/null +++ b/core/modules/serialization/tests/src/Unit/Normalizer/DateTimeNormalizerTest.php @@ -0,0 +1,192 @@ +prophesize(ImmutableConfig::class); + $system_date_config->get('timezone.default') + ->willReturn('Australia/Sydney'); + $config_factory = $this->prophesize(ConfigFactoryInterface::class); + $config_factory->get('system.date') + ->willReturn($system_date_config->reveal()); + + $this->normalizer = new DateTimeNormalizer($config_factory->reveal()); + $this->data = $this->prophesize(DateTimeInterface::class); + } + + /** + * @covers ::supportsNormalization + */ + public function testSupportsNormalization() { + $this->assertTrue($this->normalizer->supportsNormalization($this->data->reveal())); + + $datetimeiso8601 = $this->prophesize(DateTimeIso8601::class); + $this->assertTrue($this->normalizer->supportsNormalization($datetimeiso8601->reveal())); + + $integer = $this->prophesize(IntegerData::class); + $this->assertFalse($this->normalizer->supportsNormalization($integer->reveal())); + } + + /** + * @covers ::supportsDenormalization + */ + public function testSupportsDenormalization() { + $this->assertTrue($this->normalizer->supportsDenormalization($this->data->reveal(), DateTimeInterface::class)); + } + + /** + * @covers ::normalize + */ + public function testNormalize() { + $random_rfc_3339_string = $this->randomMachineName(); + + $drupal_date_time = $this->prophesize(DateTimeNormalizerTestDrupalDateTime::class); + $drupal_date_time->setTimezone(new \DateTimeZone('Australia/Sydney')) + ->willReturn($drupal_date_time->reveal()); + $drupal_date_time->format(\DateTime::RFC3339) + ->willReturn($random_rfc_3339_string); + + $this->data->getDateTime() + ->willReturn($drupal_date_time->reveal()); + + $normalized = $this->normalizer->normalize($this->data->reveal()); + $this->assertSame($random_rfc_3339_string, $normalized); + } + + /** + * @covers ::normalize + */ + public function testNormalizeWhenNull() { + $this->data->getDateTime() + ->willReturn(NULL); + + $normalized = $this->normalizer->normalize($this->data->reveal()); + $this->assertNull($normalized); + } + + /** + * Tests the denormalize function with good data. + * + * @covers ::denormalize + * @dataProvider providerTestDenormalizeValidFormats + */ + public function testDenormalizeValidFormats($normalized, $expected) { + $denormalized = $this->normalizer->denormalize($normalized, DateTimeInterface::class, NULL, []); + $this->assertSame(0, $denormalized->getTimestamp() - $expected->getTimestamp()); + $this->assertEquals($expected, $denormalized); + } + + /** + * Data provider for testDenormalizeValidFormats. + * + * @return array + */ + public function providerTestDenormalizeValidFormats() { + $data = []; + + $data['RFC3339'] = ['2016-11-06T09:02:00+00:00', new \DateTimeImmutable('2016-11-06T09:02:00+00:00')]; + $data['RFC3339 +0100'] = ['2016-11-06T09:02:00+01:00', new \DateTimeImmutable('2016-11-06T09:02:00+01:00')]; + $data['RFC3339 -0600'] = ['2016-11-06T09:02:00-06:00', new \DateTimeImmutable('2016-11-06T09:02:00-06:00')]; + + $data['ISO8601'] = ['2016-11-06T09:02:00+0000', new \DateTimeImmutable('2016-11-06T09:02:00+00:00')]; + $data['ISO8601 +0100'] = ['2016-11-06T09:02:00+0100', new \DateTimeImmutable('2016-11-06T09:02:00+01:00')]; + $data['ISO8601 -0600'] = ['2016-11-06T09:02:00-0600', new \DateTimeImmutable('2016-11-06T09:02:00-06:00')]; + + return $data; + } + + /** + * Tests the denormalize function with a user supplied format. + * + * @covers ::denormalize + * @dataProvider providerTestDenormalizeUserFormats + */ + public function testDenormalizeUserFormats($normalized, $format, $expected) { + $denormalized = $this->normalizer->denormalize($normalized, DateTimeInterface::class, NULL, ['datetime_allowed_formats' => [$format]]); + $this->assertSame(0, $denormalized->getTimestamp() - $expected->getTimestamp()); + $this->assertEquals($expected, $denormalized); + } + + /** + * Data provider for testDenormalizeUserFormats. + * + * @return array + */ + public function providerTestDenormalizeUserFormats() { + $data = []; + + $data['Y/m/d H:i:s P'] = ['2016/11/06 09:02:00 +00:00', 'Y/m/d H:i:s P', new \DateTimeImmutable('2016-11-06T09:02:00+00:00')]; + $data['H:i:s Y/m/d P'] = ['09:02:00 2016/11/06 +01:00', 'H:i:s Y/m/d P', new \DateTimeImmutable('2016-11-06T09:02:00+01:00')]; + $data['Y/m/d H:i:s'] = ['09:02:00 2016/11/06', 'H:i:s Y/m/d', new \DateTimeImmutable('2016-11-06T09:02:00+11:00')]; + + 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: "Y-m-d\TH:i:sP" (RFC 3339), "Y-m-d\TH:i:sO" (ISO 8601).'); + + $normalized = '2016/11/06 09:02am GMT'; + + $this->normalizer->denormalize($normalized, DateTimeInterface::class, NULL, []); + } + +} + + +/** + * Note: Prophecy does not support magic methods. By subclassing and specifying + * an explicit method, Prophecy works. + * @see https://github.com/phpspec/prophecy/issues/338 + * @see https://github.com/phpspec/prophecy/issues/34 + * @see https://github.com/phpspec/prophecy/issues/80 + */ +class DateTimeNormalizerTestDrupalDateTime extends DrupalDateTime { + + public function setTimezone(\DateTimeZone $timezone) { + parent::setTimezone($timezone); + } + +} diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/TimeStampItemNormalizerTraitDeprecatedTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/TimeStampItemNormalizerTraitDeprecatedTest.php new file mode 100644 index 00000000000..77623a190fc --- /dev/null +++ b/core/modules/serialization/tests/src/Unit/Normalizer/TimeStampItemNormalizerTraitDeprecatedTest.php @@ -0,0 +1,25 @@ + '2016-11-06T09:02:00+00:00', 'format' => \DateTime::RFC3339]; - + // Mock TimestampItem @FieldType, which contains a Timestamp @DataType, + // which has a DataDefinition. + $data_definition = $this->prophesize(DataDefinitionInterface::class); + $data_definition->isInternal() + ->willReturn(FALSE) + ->shouldBeCalled(); + $timestamp = $this->prophesize(Timestamp::class); + $timestamp->getDataDefinition() + ->willReturn($data_definition->reveal()) + ->shouldBeCalled(); + $timestamp = $timestamp->reveal(); $timestamp_item = $this->createTimestampItemProphecy(); - $timestamp_item->getIterator() - ->willReturn(new \ArrayIterator(['value' => 1478422920])); - - $value_property = $this->getTypedDataProperty(FALSE); $timestamp_item->getProperties(TRUE) - ->willReturn(['value' => $value_property]) + ->willReturn(['value' => $timestamp]) ->shouldBeCalled(); + // Mock Serializer service, to assert that the Timestamp @DataType + // normalizer would be called. + $timestamp_datetype_normalization = $this->randomMachineName(); $serializer_prophecy = $this->prophesize(Serializer::class); - - $serializer_prophecy->normalize($value_property, NULL, []) - ->willReturn(1478422920) + // This is where \Drupal\serialization\Normalizer\TimestampNormalizer would + // be called. + $serializer_prophecy->normalize($timestamp, NULL, []) + ->willReturn($timestamp_datetype_normalization) ->shouldBeCalled(); $this->normalizer->setSerializer($serializer_prophecy->reveal()); $normalized = $this->normalizer->normalize($timestamp_item->reveal()); - $this->assertSame($expected, $normalized); + $this->assertSame(['value' => $timestamp_datetype_normalization, 'format' => \DateTime::RFC3339], $normalized); } /** - * Tests the denormalize function with good data. - * * @covers ::denormalize - * @dataProvider providerTestDenormalizeValidFormats */ - public function testDenormalizeValidFormats($value, $expected) { - $normalized = ['value' => $value]; + public function testDenormalize() { + $timestamp_item_normalization = [ + 'value' => $this->randomMachineName(), + 'format' => \DateTime::RFC3339, + ]; + $timestamp_data_denormalization = $this->randomMachineName(); $timestamp_item = $this->createTimestampItemProphecy(); - // The field item should be set with the expected timestamp. - $timestamp_item->setValue(['value' => $expected]) + // The field item should get the Timestamp @DataType denormalization set as + // a value, in FieldItemNormalizer::denormalize(). + $timestamp_item->setValue(['value' => $timestamp_data_denormalization]) ->shouldBeCalled(); - $context = ['target_instance' => $timestamp_item->reveal()]; + $context = [ + 'target_instance' => $timestamp_item->reveal(), + 'datetime_allowed_formats' => [\DateTime::RFC3339], + ]; - $denormalized = $this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context); + // Mock Serializer service, to assert that the Timestamp @DataType + // denormalizer would be called. + $serializer_prophecy = $this->prophesize(Serializer::class); + // This is where \Drupal\serialization\Normalizer\TimestampNormalizer would + // be called. + $serializer_prophecy->denormalize($timestamp_item_normalization['value'], Timestamp::class, NULL, $context) + ->willReturn($timestamp_data_denormalization) + ->shouldBeCalled(); + + $this->normalizer->setSerializer($serializer_prophecy->reveal()); + + $denormalized = $this->normalizer->denormalize($timestamp_item_normalization, 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. * diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/TimestampNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/TimestampNormalizerTest.php new file mode 100644 index 00000000000..e7ddb8656ac --- /dev/null +++ b/core/modules/serialization/tests/src/Unit/Normalizer/TimestampNormalizerTest.php @@ -0,0 +1,147 @@ +normalizer = new TimestampNormalizer($this->prophesize(ConfigFactoryInterface::class)->reveal()); + $this->data = $this->prophesize(Timestamp::class); + } + + /** + * @covers ::supportsNormalization + */ + public function testSupportsNormalization() { + $this->assertTrue($this->normalizer->supportsNormalization($this->data->reveal())); + + $integer = $this->prophesize(IntegerData::class); + $this->assertFalse($this->normalizer->supportsNormalization($integer->reveal())); + + $datetime = $this->prophesize(DateTimeInterface::class); + $this->assertFalse($this->normalizer->supportsNormalization($datetime->reveal())); + } + + /** + * @covers ::supportsDenormalization + */ + public function testSupportsDenormalization() { + $this->assertTrue($this->normalizer->supportsDenormalization($this->data->reveal(), Timestamp::class)); + } + + /** + * @covers ::normalize + */ + public function testNormalize() { + $random_rfc_3339_string = $this->randomMachineName(); + + $drupal_date_time = $this->prophesize(TimestampNormalizerTestDrupalDateTime::class); + $drupal_date_time->setTimezone(new \DateTimeZone('UTC')) + ->willReturn($drupal_date_time->reveal()); + $drupal_date_time->format(\DateTime::RFC3339) + ->willReturn($random_rfc_3339_string); + + $this->data->getDateTime() + ->willReturn($drupal_date_time->reveal()); + + $normalized = $this->normalizer->normalize($this->data->reveal()); + $this->assertSame($random_rfc_3339_string, $normalized); + } + + /** + * Tests the denormalize function with good data. + * + * @covers ::denormalize + * @dataProvider providerTestDenormalizeValidFormats + */ + public function testDenormalizeValidFormats($normalized, $expected) { + $denormalized = $this->normalizer->denormalize($normalized, Timestamp::class, NULL, []); + $this->assertSame($expected, $denormalized); + } + + /** + * 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).'); + + $normalized = '2016/11/06 09:02am GMT'; + + $this->normalizer->denormalize($normalized, Timestamp::class, NULL, []); + } + +} + +/** + * Note: Prophecy does not support magic methods. By subclassing and specifying + * an explicit method, Prophecy works. + * @see https://github.com/phpspec/prophecy/issues/338 + * @see https://github.com/phpspec/prophecy/issues/34 + * @see https://github.com/phpspec/prophecy/issues/80 + */ +class TimestampNormalizerTestDrupalDateTime extends DrupalDateTime { + + public function setTimezone(\DateTimeZone $timezone) { + parent::setTimezone($timezone); + } + +}