diff --git a/core/lib/Drupal/Core/Config/Schema/ArrayElement.php b/core/lib/Drupal/Core/Config/Schema/ArrayElement.php index b38a94b3c2..ae96d2d6e1 100644 --- a/core/lib/Drupal/Core/Config/Schema/ArrayElement.php +++ b/core/lib/Drupal/Core/Config/Schema/ArrayElement.php @@ -3,12 +3,15 @@ namespace Drupal\Core\Config\Schema; use Drupal\Core\TypedData\ComplexDataInterface; +use Drupal\Core\TypedData\ComplexDataWithExposedPropertiesTrait; /** * Defines a generic configuration element that contains multiple properties. */ abstract class ArrayElement extends Element implements \IteratorAggregate, TypedConfigInterface, ComplexDataInterface { + use ComplexDataWithExposedPropertiesTrait; + /** * Parsed elements. */ diff --git a/core/lib/Drupal/Core/Entity/Plugin/DataType/EntityAdapter.php b/core/lib/Drupal/Core/Entity/Plugin/DataType/EntityAdapter.php index eafb6d88bf..311f4080e2 100644 --- a/core/lib/Drupal/Core/Entity/Plugin/DataType/EntityAdapter.php +++ b/core/lib/Drupal/Core/Entity/Plugin/DataType/EntityAdapter.php @@ -6,6 +6,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\TypedData\EntityDataDefinition; use Drupal\Core\TypedData\ComplexDataInterface; +use Drupal\Core\TypedData\ComplexDataWithExposedPropertiesTrait; use Drupal\Core\TypedData\Exception\MissingDataException; use Drupal\Core\TypedData\TypedData; @@ -28,6 +29,7 @@ */ class EntityAdapter extends TypedData implements \IteratorAggregate, ComplexDataInterface { + use ComplexDataWithExposedPropertiesTrait; /** * The wrapped entity object. * diff --git a/core/lib/Drupal/Core/Field/FieldConfigBase.php b/core/lib/Drupal/Core/Field/FieldConfigBase.php index 76366bfe21..f3e254b7f8 100644 --- a/core/lib/Drupal/Core/Field/FieldConfigBase.php +++ b/core/lib/Drupal/Core/Field/FieldConfigBase.php @@ -6,12 +6,14 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\TypedData\FieldItemDataDefinition; +use Drupal\Core\TypedData\ExposableDataDefinitionTrait; /** * Base class for configurable field definitions. */ abstract class FieldConfigBase extends ConfigEntityBase implements FieldConfigInterface { + use ExposableDataDefinitionTrait; /** * The field ID. * diff --git a/core/lib/Drupal/Core/TypedData/ComplexDataInterface.php b/core/lib/Drupal/Core/TypedData/ComplexDataInterface.php index 6396cb8f51..5b6b58b4a4 100644 --- a/core/lib/Drupal/Core/TypedData/ComplexDataInterface.php +++ b/core/lib/Drupal/Core/TypedData/ComplexDataInterface.php @@ -102,4 +102,12 @@ public function toArray(); */ public function isEmpty(); + /** + * Gets an array of property objects for exposed properties. + * + * @return \Drupal\Core\TypedData\TypedDataInterface[] + * The exposed properties. + */ + public function getExposedProperties(); + } diff --git a/core/lib/Drupal/Core/TypedData/ComplexDataWithExposedPropertiesTrait.php b/core/lib/Drupal/Core/TypedData/ComplexDataWithExposedPropertiesTrait.php new file mode 100644 index 0000000000..69e3d61a50 --- /dev/null +++ b/core/lib/Drupal/Core/TypedData/ComplexDataWithExposedPropertiesTrait.php @@ -0,0 +1,27 @@ +getProperties(TRUE); + /* @var \Drupal\Core\TypedData\TypedDataInterface[] $properties */ + foreach (array_keys($properties) as $property_name) { + if (!$properties[$property_name]->getDataDefinition()->isExposed()) { + unset($properties[$property_name]); + } + } + return $properties; + } + +} diff --git a/core/lib/Drupal/Core/TypedData/DataDefinition.php b/core/lib/Drupal/Core/TypedData/DataDefinition.php index 52a4394cd7..6e0f936a8f 100644 --- a/core/lib/Drupal/Core/TypedData/DataDefinition.php +++ b/core/lib/Drupal/Core/TypedData/DataDefinition.php @@ -9,6 +9,8 @@ class DataDefinition implements DataDefinitionInterface, \ArrayAccess { use TypedDataTrait; + use ExposableDataDefinitionTrait; + /** * The array holding values for all definition keys. * diff --git a/core/lib/Drupal/Core/TypedData/DataDefinitionInterface.php b/core/lib/Drupal/Core/TypedData/DataDefinitionInterface.php index 64d779c317..afaf4f441f 100644 --- a/core/lib/Drupal/Core/TypedData/DataDefinitionInterface.php +++ b/core/lib/Drupal/Core/TypedData/DataDefinitionInterface.php @@ -218,4 +218,23 @@ public function getConstraint($constraint_name); */ public function addConstraint($constraint_name, $options = NULL); + /** + * Sets the whether the data value should be exposed. + * + * @param bool $exposed + * Whether the data value is exposed. + * + * @return static + * The object itself for chaining. + */ + public function setExposed($exposed); + + /** + * Determines whether the data value is exposed. + * + * @return bool + * Whether the data value is exposed. + */ + public function isExposed(); + } diff --git a/core/lib/Drupal/Core/TypedData/ExposableDataDefinitionTrait.php b/core/lib/Drupal/Core/TypedData/ExposableDataDefinitionTrait.php new file mode 100644 index 0000000000..708e40f3a9 --- /dev/null +++ b/core/lib/Drupal/Core/TypedData/ExposableDataDefinitionTrait.php @@ -0,0 +1,34 @@ +isComputed() && !isset($this->definition['exposed'])) { + return TRUE; + } + return !empty($this->definition['exposed']); + } + + /** + * {@inheritdoc} + * + * Implements setExposed() for \Drupal\Core\TypedData\DataDefinitionInterface. + */ + public function setExposed($exposed) { + $this->definition['exposed'] = $exposed; + return $this; + } + +} diff --git a/core/lib/Drupal/Core/TypedData/Plugin/DataType/Map.php b/core/lib/Drupal/Core/TypedData/Plugin/DataType/Map.php index a95c14b30e..a3b2657c9f 100644 --- a/core/lib/Drupal/Core/TypedData/Plugin/DataType/Map.php +++ b/core/lib/Drupal/Core/TypedData/Plugin/DataType/Map.php @@ -2,6 +2,7 @@ namespace Drupal\Core\TypedData\Plugin\DataType; +use Drupal\Core\TypedData\ComplexDataWithExposedPropertiesTrait; use Drupal\Core\TypedData\TypedData; use Drupal\Core\TypedData\ComplexDataInterface; @@ -26,6 +27,8 @@ */ class Map extends TypedData implements \IteratorAggregate, ComplexDataInterface { + use ComplexDataWithExposedPropertiesTrait; + /** * The data definition. * diff --git a/core/modules/hal/src/Normalizer/FieldItemNormalizer.php b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php index dc5aec9899..d162ff106f 100644 --- a/core/modules/hal/src/Normalizer/FieldItemNormalizer.php +++ b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php @@ -3,6 +3,8 @@ namespace Drupal\hal\Normalizer; use Drupal\Core\Field\FieldItemInterface; +use Drupal\serialization\Normalizer\ComputedPropertiesNormalizerTrait; +use Drupal\serialization\Normalizer\ComplexDataPropertiesNormalizerTrait; use Symfony\Component\Serializer\Exception\InvalidArgumentException; /** @@ -10,6 +12,8 @@ */ class FieldItemNormalizer extends NormalizerBase { + use ComplexDataPropertiesNormalizerTrait; + /** * The interface or class that this Normalizer supports. * @@ -21,13 +25,8 @@ 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); - } + /** @var \Drupal\Core\Field\FieldItemInterface $field_item */ + $values = $this->normalizeProperties($field_item, $format, $context); if (isset($context['langcode'])) { $values['lang'] = $context['langcode']; diff --git a/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php b/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php index df77281f37..7dc6585283 100644 --- a/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php +++ b/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php @@ -2,6 +2,7 @@ namespace Drupal\rest\EventSubscriber; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheableResponse; use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Render\RenderContext; @@ -126,11 +127,18 @@ public function getResponseFormat(RouteMatchInterface $route_match, Request $req /** * Renders a resource response body. * - * Serialization can invoke rendering (e.g., generating URLs), but the - * serialization API does not provide a mechanism to collect the - * bubbleable metadata associated with that (e.g., language and other - * contexts), so instead, allow those to "leak" and collect them here in - * a render context. + * During serialization, encoders and normalizers are able to explicitly + * bubble cacheability metadata via the 'cacheability' key-value pair in the + * received context. This bubbled cacheability metadata will be applied to the + * the response. + * + * In prior versions of Drupal 8, we allowed implicit bubbling of cacheability + * metadata because there was no explicit cacheability metadata bubbling API. + * To maintain backwards compatibility, we continue to support this, but + * support for this will be dropped in Drupal 9.0.0. This is especially useful + * when interacting with APIs that implicitly invoke rendering (for example: + * generating URLs): this allows those to "leak", and we collect their bubbled + * cacheability metadata automatically in a render context. * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. @@ -150,14 +158,25 @@ protected function renderResponseBody(Request $request, ResourceResponseInterfac // If there is data to send, serialize and set it as the response body. if ($data !== NULL) { + $serialization_context = [ + 'request' => $request, + 'cacheability' => new CacheableMetadata(), + ]; + + // @deprecated In Drupal 8.4.0, will be removed before Drupal 9.0.0. Use + // explicit cacheability metadata bubbling instead. (The wrapping call to + // executeInRenderContext() will be removed before Drupal 9.0.0.) $context = new RenderContext(); $output = $this->renderer - ->executeInRenderContext($context, function () use ($serializer, $data, $format) { - return $serializer->serialize($data, $format); + ->executeInRenderContext($context, function() use ($serializer, $data, $format, $serialization_context) { + return $serializer->serialize($data, $format, $serialization_context); }); - - if ($response instanceof CacheableResponseInterface && !$context->isEmpty()) { - $response->addCacheableDependency($context->pop()); + if ($response instanceof CacheableResponseInterface) { + if (!$context->isEmpty()) { + @trigger_error('Implicit cacheability metadata bubbling (onto the global render context) in normalizers is deprecated since Drupal 8.4.0 and will be removed in Drupal 9.0.0. Use the "cacheability" serialization context instead, for explicit cacheability metadata bubbling.', E_USER_DEPRECATED); + $response->addCacheableDependency($context->pop()); + } + $response->addCacheableDependency($serialization_context['cacheability']); } $response->setContent($output); diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestExposedPropertyNormalizerTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestExposedPropertyNormalizerTest.php new file mode 100644 index 0000000000..27729afa82 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestExposedPropertyNormalizerTest.php @@ -0,0 +1,101 @@ + 'value to expose', + 'exposed_value' => 'Exposed! value to expose', + ], + ]; + return $expected; + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + if (!FieldStorageConfig::loadByName('entity_test', 'field_test_exposed')) { + // Auto-create a field for testing. + FieldStorageConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_test_exposed', + 'type' => 'exposed_string_test', + 'cardinality' => 1, + 'translatable' => FALSE, + ])->save(); + FieldConfig::create([ + 'entity_type' => 'entity_test', + 'field_name' => 'field_test_exposed', + 'bundle' => 'entity_test', + 'label' => 'Test exposed-field', + ])->save(); + } + + $entity = parent::createEntity(); + $entity->field_test_exposed = [ + 'value' => 'value to expose', + ]; + $entity->save(); + return $entity; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + $post_entity = parent::getNormalizedPostEntity(); + $post_entity['field_test_exposed'] = [ + [ + 'value' => 'value to expose', + ], + ]; + return $post_entity; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['request_format']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheTags() { + return Cache::mergeTags(parent::getExpectedCacheTags(), ['you_are_it', 'no_tag_backs']); + } + +} diff --git a/core/modules/serialization/src/Normalizer/ComplexDataNormalizer.php b/core/modules/serialization/src/Normalizer/ComplexDataNormalizer.php index 3d2031218f..afb40c8e31 100644 --- a/core/modules/serialization/src/Normalizer/ComplexDataNormalizer.php +++ b/core/modules/serialization/src/Normalizer/ComplexDataNormalizer.php @@ -1,6 +1,7 @@ $field) { - $attributes[$name] = $this->serializer->normalize($field, $format, $context); + // $object will not always match $supportedInterfaceOrClass. + // @see \Drupal\serialization\Normalizer\EntityNormalizer + // Other normalizer that extend this class may only provide $object that + // implements \Traversable. + if ($object instanceof ComplexDataInterface) { + $attributes = $this->normalizeProperties($object, $format, $context); + } + else { + $attributes = []; + /** @var \Drupal\Core\TypedData\TypedDataInterface $field */ + foreach ($object as $name => $property) { + $attributes[$name] = $this->serializer->normalize($property, $format, $context); + } } return $attributes; } diff --git a/core/modules/serialization/src/Normalizer/ComplexDataPropertiesNormalizerTrait.php b/core/modules/serialization/src/Normalizer/ComplexDataPropertiesNormalizerTrait.php new file mode 100644 index 0000000000..0969629faf --- /dev/null +++ b/core/modules/serialization/src/Normalizer/ComplexDataPropertiesNormalizerTrait.php @@ -0,0 +1,41 @@ +getExposedProperties() as $name => $property) { + $attribute = $this->serializer->normalize($property, $format, $context); + if ($attribute instanceof CacheableDependencyInterface && isset($context['cacheability'])) { + $context['cacheability']->addCacheableDependency($attribute); + } + if (is_object($attribute) && method_exists($attribute, '__toString')) { + $attribute = (string) $attribute; + } + $attributes[$name] = $attribute; + } + return $attributes; + } + +} diff --git a/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php index decca43227..c2f62f1d59 100644 --- a/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php +++ b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php @@ -11,6 +11,8 @@ */ class FieldItemNormalizer extends ComplexDataNormalizer implements DenormalizerInterface { + use ComplexDataPropertiesNormalizerTrait; + /** * {@inheritdoc} */ diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/ComplexDataNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/ComplexDataNormalizerTest.php index dc14d6f428..f59e15f279 100644 --- a/core/modules/serialization/tests/src/Unit/Normalizer/ComplexDataNormalizerTest.php +++ b/core/modules/serialization/tests/src/Unit/Normalizer/ComplexDataNormalizerTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\serialization\Unit\Normalizer; use Drupal\Core\TypedData\ComplexDataInterface; +use Drupal\Core\TypedData\ComplexDataWithExposedPropertiesTrait; use Drupal\Core\TypedData\TraversableTypedDataInterface; use Drupal\serialization\Normalizer\ComplexDataNormalizer; use Drupal\Tests\UnitTestCase; @@ -76,6 +77,8 @@ public function testNormalize() { */ class TestComplexData implements \IteratorAggregate, ComplexDataInterface { + use ComplexDataWithExposedPropertiesTrait; + private $values; public function __construct(array $values = []) { diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php index e0561a1003..a107250865 100644 --- a/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php +++ b/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php @@ -4,6 +4,8 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\TypedData\Plugin\DataType\StringData; +use Drupal\Core\TypedData\Type\StringInterface; use Drupal\Core\TypedData\TypedDataInterface; use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\FieldableEntityInterface; @@ -122,6 +124,10 @@ public function testNormalize() { ->willReturn($entity_reference) ->shouldBeCalled(); + $this->fieldItem->getExposedProperties() + ->willReturn(['target_id' => NULL]) + ->shouldBeCalled(); + $normalized = $this->normalizer->normalize($this->fieldItem->reveal()); $expected = [ @@ -146,6 +152,10 @@ public function testNormalizeWithNoEntity() { ->willReturn($entity_reference->reveal()) ->shouldBeCalled(); + $this->fieldItem->getExposedProperties() + ->willReturn(['target_id' => NULL]) + ->shouldBeCalled(); + $normalized = $this->normalizer->normalize($this->fieldItem->reveal()); $expected = [ diff --git a/core/modules/system/tests/modules/entity_test/config/schema/entity_test.data_types.schema.yml b/core/modules/system/tests/modules/entity_test/config/schema/entity_test.data_types.schema.yml new file mode 100644 index 0000000000..bda7a93205 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/config/schema/entity_test.data_types.schema.yml @@ -0,0 +1,15 @@ +# Schema for the configuration of the exposed string field type. + +field.storage_settings.exposed_string_test: + type: mapping + label: 'String settings' + mapping: + max_length: + type: integer + label: 'Maximum length' + case_sensitive: + type: boolean + label: 'Case sensitive' + is_ascii: + type: boolean + label: 'Contains US ASCII characters only' diff --git a/core/modules/system/tests/modules/entity_test/src/ComputedString.php b/core/modules/system/tests/modules/entity_test/src/ComputedString.php new file mode 100644 index 0000000000..0c78bdc02d --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/ComputedString.php @@ -0,0 +1,41 @@ +notComputedValue = $not_computed_value; + $this->cacheContexts = ['request_format']; + $this->cacheTags = ['you_are_it', 'no_tag_backs']; + } + + /** + * {@inheritdoc} + */ + public function __toString() { + // Computation is simple concatenation for test. + return "Exposed! " . $this->notComputedValue; + } + +} diff --git a/core/modules/system/tests/modules/entity_test/src/Plugin/DataType/ExposedStringData.php b/core/modules/system/tests/modules/entity_test/src/Plugin/DataType/ExposedStringData.php new file mode 100644 index 0000000000..650121754f --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Plugin/DataType/ExposedStringData.php @@ -0,0 +1,37 @@ +getParent(); + $computed = new ComputedString($item->get('value')->getString()); + return $computed; + } + + /** + * {@inheritdoc} + */ + public function getCastedValue() { + return $this->getValue(); + } + +} diff --git a/core/modules/system/tests/modules/entity_test/src/Plugin/Field/FieldType/ExposedPropertyTestFieldItem.php b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/FieldType/ExposedPropertyTestFieldItem.php new file mode 100644 index 0000000000..fb930826b2 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/FieldType/ExposedPropertyTestFieldItem.php @@ -0,0 +1,39 @@ +setLabel(new TranslatableMarkup('Text value exposed')) + ->setComputed(TRUE) + ->setClass(ExposedStringData::class) + ->setExposed(TRUE); + return $properties; + } + +}