Commit 4b455afc authored by webchick's avatar webchick
Browse files

Issue #1931976 by linclark, effulgentsia: Support deserialization for hal+json.

parent 20ec5dc1
......@@ -30,4 +30,11 @@ public function supportsEncoding($format) {
return $format == $this->format;
}
/**
* Overrides \Symfony\Component\Serializer\Encoder\JsonEncoder::supportsDecoding()
*/
public function supportsDecoding($format) {
return $format == $this->format;
}
}
......@@ -9,6 +9,7 @@
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityNG;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
* Converts the Drupal entity object structure to a HAL array structure.
......@@ -61,6 +62,56 @@ public function normalize($entity, $format = NULL, array $context = array()) {
return $normalized;
}
/**
* Implements \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::denormalize().
*
* @throws \Symfony\Component\Serializer\Exception\UnexpectedValueException
*/
public function denormalize($data, $class, $format = NULL, array $context = array()) {
// Get type, necessary for determining which bundle to create.
if (!isset($data['_links']['type'])) {
throw new UnexpectedValueException('The type link relation must be specified.');
}
// Get language.
$langcode = isset($data['langcode']) ? $data['langcode'][0]['value'] : LANGUAGE_NOT_SPECIFIED;
// Create the entity.
$typed_data_ids = $this->getTypedDataIds($data['_links']['type']);
$entity = entity_create($typed_data_ids['entity_type'], array('langcode' => $langcode, 'type' => $typed_data_ids['bundle']));
// Get links and remove from data array.
$links = $data['_links'];
unset($data['_links']);
// Get embedded resources and remove from data array.
$embedded = array();
if (isset($data['_embedded'])) {
$embedded = $data['_embedded'];
unset($data['_embedded']);
}
// Iterate through remaining items in data array. These should all
// correspond to fields.
foreach ($data as $field_name => $field_data) {
// Remove any values that were set as a part of entity creation (e.g
// uuid). If this field is set to an empty array in the data, this will
// also have the effect of marking the field for deletion in REST module.
$entity->{$field_name} = array();
$field = $entity->get($field_name);
// Get the class of the field. This will generally be the default Field
// class.
$class = get_class($field);
// Pass in the empty field object as a target instance. Since the context
// is already prepared for the field, any data added to it is
// automatically added to the entity.
$context['target_instance'] = $field;
$this->serializer->denormalize($field_data, $class, $format, $context);
}
return $entity;
}
/**
* Constructs the entity URI.
*
......@@ -75,4 +126,44 @@ protected function getEntityUri($entity) {
return url($uri_info['path'], array('absolute' => TRUE));
}
/**
* Gets the typed data IDs for a type URI.
*
* @param array $types
* The type array(s) (value of the 'type' attribute of the incoming data).
*
* @return array
* The typed data IDs.
*
* @throws \Symfony\Component\Serializer\Exception\UnexpectedValueException
*/
protected function getTypedDataIds($types) {
// The 'type' can potentially contain an array of type objects. By default,
// Drupal only uses a single type in serializing, but allows for multiple
// types when deserializing.
if (isset($types['href'])) {
$types = array($types);
}
foreach ($types as $type) {
if (!isset($type['href'])) {
throw new UnexpectedValueException('Type must contain an \'href\' attribute.');
}
$type_uri = $type['href'];
// Check whether the URI corresponds to a known type on this site. Break
// once one does.
if ($typed_data_ids = $this->linkManager->getTypeInternalIds($type['href'])) {
break;
}
}
// If none of the URIs correspond to an entity type on this site, no entity
// can be created. Throw an exception.
if (empty($typed_data_ids)) {
throw new UnexpectedValueException(sprintf('Type %s does not correspond to an entity on this site.', $type_uri));
}
return $typed_data_ids;
}
}
......@@ -57,4 +57,11 @@ public function normalize($field_item, $format = NULL, array $context = array())
);
}
/**
* Implements \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::denormalize()
*/
public function denormalize($data, $class, $format = NULL, array $context = array()) {
// @todo Implement this in http://drupal.org/node/1880424
}
}
......@@ -7,6 +7,8 @@
namespace Drupal\hal\Normalizer;
use Drupal\Core\Entity\Field\FieldItemInterface;
/**
* Converts the Drupal field item object structure to HAL array structure.
*/
......@@ -38,4 +40,72 @@ public function normalize($field_item, $format = NULL, array $context = array())
);
}
/**
* Implements \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::denormalize()
*/
public function denormalize($data, $class, $format = NULL, array $context = array()) {
if (!isset($context['target_instance'])) {
throw new LogicException('$context[\'target_instance\'] must be set to denormalize with the FieldItemNormalizer');
}
if ($context['target_instance']->getParent() == NULL) {
throw new LogicException('The field item passed in via $context[\'target_instance\'] must have a parent set.');
}
$field_item = $context['target_instance'];
// If this field is translatable, we need to create a translated instance.
if (isset($data['lang'])) {
$langcode = $data['lang'];
unset($data['lang']);
$field_definition = $field_item->getDefinition();
if ($field_definition['translatable'] == TRUE) {
$field_item = $this->createTranslatedInstance($field_item, $langcode);
}
}
$field_item->setValue($data);
return $field_item;
}
/**
* Get a translated version of the field item instance.
*
* To indicate that a field item applies to one translation of an entity and
* not another, the property path must originate with a translation of the
* entity. This is the reason for using target_instances, from which the
* property path can be traversed up to the root.
*
* @param \Drupal\Core\Entity\Field\FieldItemInterface $field_item
* The untranslated field item instance.
* @param $langcode
* The langcode.
*
* @return \Drupal\Core\Entity\Field\FieldItemInterface
* The translated field item instance.
*/
protected function createTranslatedInstance(FieldItemInterface $field_item, $langcode) {
$parent = $field_item->getParent();
$ancestors = array();
// Remove the untranslated instance from the field's list of items.
$parent->offsetUnset($field_item->getName());
// Get the property path.
while (!method_exists($parent, 'getTranslation')) {
array_unshift($ancestors, $parent);
$parent = $parent->getParent();
}
// Recreate the property path with translations.
$translation = $parent->getTranslation($langcode);
foreach ($ancestors as $ancestor) {
$ancestor_name = $ancestor->getName();
$translation = $translation->get($ancestor_name);
}
// Create a new instance at the end of the property path and return it.
$count = $translation->isEmpty() ? 0 : $translation->count();
return $translation->offsetGet($count);
}
}
......@@ -8,6 +8,7 @@
namespace Drupal\hal\Normalizer;
use Drupal\Component\Utility\NestedArray;
use Symfony\Component\Serializer\Exception\LogicException;
/**
* Converts the Drupal field structure to HAL array structure.
......@@ -26,6 +27,8 @@ class FieldNormalizer extends NormalizerBase {
*/
public function normalize($field, $format = NULL, array $context = array()) {
$normalized_field_items = array();
// Get the field definition.
$entity = $field->getParent();
$field_name = $field->getName();
$field_definition = $entity->getPropertyDefinition($field_name);
......@@ -53,6 +56,34 @@ public function normalize($field, $format = NULL, array $context = array()) {
return $normalized;
}
/**
* Implements \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::denormalize()
*/
public function denormalize($data, $class, $format = NULL, array $context = array()) {
if (!isset($context['target_instance'])) {
throw new LogicException('$context[\'target_instance\'] must be set to denormalize with the FieldNormalizer');
}
if ($context['target_instance']->getParent() == NULL) {
throw new LogicException('The field passed in via $context[\'target_instance\'] must have a parent set.');
}
$field = $context['target_instance'];
foreach ($data as $field_item_data) {
$count = $field->count();
// Get the next field item instance. The offset will serve as the field
// item name.
$field_item = $field->offsetGet($count);
$field_item_class = get_class($field_item);
// Pass in the empty field item object as the target instance.
$context['target_instance'] = $field_item;
$this->serializer->denormalize($field_item_data, $field_item_class, $format, $context);
}
return $field;
}
/**
* Helper function to normalize field items.
*
......
......@@ -8,11 +8,12 @@
namespace Drupal\hal\Normalizer;
use Drupal\serialization\Normalizer\NormalizerBase as SerializationNormalizerBase;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Base class for Normalizers.
*/
abstract class NormalizerBase extends SerializationNormalizerBase {
abstract class NormalizerBase extends SerializationNormalizerBase implements DenormalizerInterface {
/**
* The formats that the Normalizer can handle.
......@@ -35,6 +36,22 @@ public function supportsNormalization($data, $format = NULL) {
return in_array($format, $this->formats) && parent::supportsNormalization($data, $format);
}
/**
* Implements \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::supportsDenormalization()
*/
public function supportsDenormalization($data, $type, $format = NULL) {
if (in_array($format, $this->formats)) {
$target = new \ReflectionClass($type);
$supported = new \ReflectionClass($this->supportedInterfaceOrClass);
if ($supported->isInterface()) {
return $target->implementsInterface($this->supportedInterfaceOrClass);
}
else {
return ($target->getName() == $this->supportedInterfaceOrClass || $target->isSubclassOf($this->supportedInterfaceOrClass));
}
}
}
/**
* Sets the link manager.
*
......
<?php
/**
* @file
* Contains \Drupal\hal\Tests\DenormalizeTest.
*/
namespace Drupal\hal\Tests;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
* Test the HAL normalizer's denormalize function.
*/
class DenormalizeTest extends NormalizerTestBase {
public static function getInfo() {
return array(
'name' => 'Denormalize Test',
'description' => 'Test that entities can be denormalized from HAL.',
'group' => 'HAL',
);
}
/**
* Tests that the type link relation in incoming data is handled correctly.
*/
public function testTypeHandling() {
// Valid type.
$data_with_valid_type = array(
'_links' => array(
'type' => array(
'href' => url('rest/type/entity_test/entity_test', array('absolute' => TRUE)),
),
),
);
$denormalized = $this->serializer->denormalize($data_with_valid_type, $this->entityClass, $this->format);
$this->assertEqual(get_class($denormalized), $this->entityClass, 'Request with valid type results in creation of correct bundle.');
// Multiple types.
$data_with_multiple_types = array(
'_links' => array(
'type' => array(
array(
'href' => url('rest/types/foo', array('absolute' => TRUE)),
),
array(
'href' => url('rest/type/entity_test/entity_test', array('absolute' => TRUE)),
),
),
),
);
$denormalized = $this->serializer->denormalize($data_with_multiple_types, $this->entityClass, $this->format);
$this->assertEqual(get_class($denormalized), $this->entityClass, 'Request with multiple types results in creation of correct bundle.');
// Invalid type.
$data_with_invalid_type = array(
'_links' => array(
'type' => array(
'href' => url('rest/types/foo', array('absolute' => TRUE)),
),
),
);
try {
$this->serializer->denormalize($data_with_invalid_type, $this->entityClass, $this->format);
$this->fail('Exception should be thrown when type is invalid.');
}
catch (UnexpectedValueException $e) {
$this->pass('Exception thrown when type is invalid.');
}
// No type.
$data_with_no_type = array(
'_links' => array(
),
);
try {
$this->serializer->denormalize($data_with_no_type, $this->entityClass, $this->format);
$this->fail('Exception should be thrown when no type is provided.');
}
catch (UnexpectedValueException $e) {
$this->pass('Exception thrown when no type is provided.');
}
}
/**
* Test that a field set to an empty array is different than an empty field.
*/
public function testMarkFieldForDeletion() {
$no_field_data = array(
'_links' => array(
'type' => array(
'href' => url('rest/type/entity_test/entity_test', array('absolute' => TRUE)),
),
),
);
$no_field_denormalized = $this->serializer->denormalize($no_field_data, $this->entityClass, $this->format);
$no_field_value = $no_field_denormalized->field_test_text->getValue();
$empty_field_data = array(
'_links' => array(
'type' => array(
'href' => url('rest/type/entity_test/entity_test', array('absolute' => TRUE)),
),
),
'field_test_text' => array(),
);
$empty_field_denormalized = $this->serializer->denormalize($empty_field_data, $this->entityClass, $this->format);
$empty_field_value = $empty_field_denormalized->field_test_text->getValue();
$this->assertTrue(!empty($no_field_value) && empty($empty_field_value), 'A field set to an empty array in the data is structured differently than an empty field.');
}
/**
* Test that non-reference fields can be denormalized.
*/
public function testBasicFieldDenormalization() {
$data = array(
'_links' => array(
'type' => array(
'href' => url('rest/type/entity_test/entity_test', array('absolute' => TRUE)),
),
),
'uuid' => array(
array(
'value' => 'e5c9fb96-3acf-4a8d-9417-23de1b6c3311',
),
),
'field_test_text' => array(
array(
'value' => $this->randomName(),
'format' => 'full_html',
),
),
'field_test_translatable_text' => array(
array(
'value' => $this->randomName(),
'format' => 'full_html',
),
array(
'value' => $this->randomName(),
'format' => 'filtered_html',
),
array(
'value' => $this->randomName(),
'format' => 'filtered_html',
'lang' => 'de',
),
array(
'value' => $this->randomName(),
'format' => 'full_html',
'lang' => 'de',
),
),
);
$expected_value_default = array(
array (
'value' => $data['field_test_translatable_text'][0]['value'],
'format' => 'full_html',
),
array (
'value' => $data['field_test_translatable_text'][1]['value'],
'format' => 'filtered_html',
),
);
$expected_value_de = array(
array (
'value' => $data['field_test_translatable_text'][2]['value'],
'format' => 'filtered_html',
),
array (
'value' => $data['field_test_translatable_text'][3]['value'],
'format' => 'full_html',
),
);
$denormalized = $this->serializer->denormalize($data, $this->entityClass, $this->format);
$this->assertEqual($data['uuid'], $denormalized->get('uuid')->getValue(), 'A preset value (e.g. UUID) is overridden by incoming data.');
$this->assertEqual($data['field_test_text'], $denormalized->get('field_test_text')->getValue(), 'A basic text field is denormalized.');
$this->assertEqual($expected_value_default, $denormalized->get('field_test_translatable_text')->getValue(), 'Values in the default language are properly handled for a translatable field.');
$this->assertEqual($expected_value_de, $denormalized->getTranslation('de')->get('field_test_translatable_text')->getValue(), 'Values in a translation language are properly handled for a translatable field.');
}
}
......@@ -150,7 +150,7 @@ public function testNormalize() {
),
);
$normalized = $this->container->get('serializer')->normalize($entity, $this->format);
$normalized = $this->serializer->normalize($entity, $this->format);
$this->assertEqual($normalized['_links']['self'], $expected_array['_links']['self'], 'self link placed correctly.');
// @todo Test curies.
// @todo Test type.
......
......@@ -7,8 +7,18 @@
namespace Drupal\hal\Tests;
use Drupal\Core\Cache\MemoryBackend;
use Drupal\Core\Language\Language;
use Drupal\hal\Encoder\JsonEncoder;
use Drupal\hal\Normalizer\EntityNormalizer;
use Drupal\hal\Normalizer\EntityReferenceItemNormalizer;
use Drupal\hal\Normalizer\FieldItemNormalizer;
use Drupal\hal\Normalizer\FieldNormalizer;
use Drupal\rest\LinkManager\LinkManager;
use Drupal\rest\LinkManager\RelationLinkManager;
use Drupal\rest\LinkManager\TypeLinkManager;
use Drupal\simpletest\DrupalUnitTestBase;
use Symfony\Component\Serializer\Serializer;
/**
* Test the HAL normalizer.
......@@ -22,6 +32,13 @@ abstract class NormalizerTestBase extends DrupalUnitTestBase {
*/
public static $modules = array('entity_test', 'entity_reference', 'field', 'field_sql_storage', 'hal', 'language', 'rest', 'serialization', 'system', 'text', 'user');
/**
* The mock serializer.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* The format being tested.
*
......@@ -29,6 +46,13 @@ abstract class NormalizerTestBase extends DrupalUnitTestBase {
*/
protected $format = 'hal_json';
/**
* The class name of the test class.
*
* @var string
*/
protected $entityClass = 'Drupal\entity_test\Plugin\Core\Entity\EntityTest';
/**
* Overrides \Drupal\simpletest\DrupalUnitTestBase::setup().
*/
......@@ -57,7 +81,6 @@ function setUp() {
$field = array(
'field_name' => 'field_test_text',
'type' => 'text',
'cardinality' => 1,
'translatable' => FALSE,
);
field_create_field($field);
......@@ -68,6 +91,20 @@ function setUp() {
);
field_create_instance($instance);
// Create the test translatable field.
$field = array(
'field_name' => 'field_test_translatable_text',
'type' => 'text',
'translatable' => TRUE,
);
field_create_field($field);
$instance = array(
'entity_type' => 'entity_test',
'field_name' => 'field_test_translatable_text',
'bundle' => 'entity_test',
);
field_create_instance($instance);
// Create the test entity reference field.
$field = array(
'translatable' => TRUE,
......@@ -84,6 +121,22 @@ function setUp() {
'bundle' => 'entity_test',
);
field_create_instance($instance);
// Set up the mock serializer.
$normalizers = array(
new EntityNormalizer(),
new EntityReferenceItemNormalizer(),
new FieldItemNormalizer(),
new FieldNormalizer(),
);
$link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend('cache')), new RelationLinkManager());
foreach ($normalizers as $normalizer) {
$normalizer->setLinkManager($link_manager);
}
$encoders = array(
new JsonEncoder(),
);
$this->serializer = new Serializer($normalizers, $encoders);
}
}
......@@ -42,6 +42,13 @@ public function getTypeUri($entity_type, $bundle) {
return $this->typeLinkManager->getTypeUri($entity_type, $bundle);