diff --git a/core/modules/hal/hal.services.yml b/core/modules/hal/hal.services.yml index 4e89841..cebec8f 100644 --- a/core/modules/hal/hal.services.yml +++ b/core/modules/hal/hal.services.yml @@ -22,6 +22,11 @@ services: arguments: ['@rest.link_manager', '@entity.manager', '@module_handler'] tags: - { name: normalizer, priority: 10 } + serializer.normalizer.collection.hal: + class: Drupal\hal\Normalizer\CollectionNormalizer + arguments: ['@rest.link_manager'] + tags: + - { name: normalizer, priority: 10 } serializer.encoder.hal: class: Drupal\hal\Encoder\JsonEncoder tags: diff --git a/core/modules/hal/src/Normalizer/CollectionNormalizer.php b/core/modules/hal/src/Normalizer/CollectionNormalizer.php new file mode 100644 index 0000000..165ecf1 --- /dev/null +++ b/core/modules/hal/src/Normalizer/CollectionNormalizer.php @@ -0,0 +1,86 @@ +linkManager = $link_manager; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = NULL, array $context = array()) { + // Create the array of normalized properties, starting with the URI. + $normalized = array( + '_links' => array( + 'self' => array( + 'href' => $object->getUri(), + ), + ), + ); + + // If we have additional hypermedia links add them here. + $links = $object->getLinks(); + if (is_array($links) && count($links)) { + foreach ($links as $key => $link) { + $normalized['_links'][$key] = array('href' => $link); + } + } + + // Add the list of items. + $link_relation = $this->linkManager->getCollectionItemRelation($object->getCollectionId()); + $normalized['_embedded'][$link_relation] = $this->serializer->normalize($object->getItems(), $format, $context); + + return $normalized; + } + + /** + * {@inheritdoc} + * + * @todo Implement denormalization once normalization has settled. + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = NULL) { + return FALSE; + } +} diff --git a/core/modules/hal/src/Tests/NormalizerTestBase.php b/core/modules/hal/src/Tests/NormalizerTestBase.php index 2ebff74..2505a8b 100644 --- a/core/modules/hal/src/Tests/NormalizerTestBase.php +++ b/core/modules/hal/src/Tests/NormalizerTestBase.php @@ -15,6 +15,7 @@ use Drupal\hal\Normalizer\FieldItemNormalizer; use Drupal\hal\Normalizer\FieldNormalizer; use Drupal\rest\LinkManager\LinkManager; +use Drupal\rest\LinkManager\CollectionLinkManager; use Drupal\rest\LinkManager\RelationLinkManager; use Drupal\rest\LinkManager\TypeLinkManager; use Drupal\serialization\EntityResolver\ChainEntityResolver; @@ -122,8 +123,11 @@ function setUp() { 'translatable' => TRUE, ))->save(); - $entity_manager = \Drupal::entityManager(); - $link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend('default')), new RelationLinkManager(new MemoryBackend('default'), $entity_manager)); + $link_manager = new LinkManager( + new TypeLinkManager(new MemoryBackend('cache')), + new RelationLinkManager(new MemoryBackend('cache'), \Drupal::entityManager()), + new CollectionLinkManager() + ); $chain_resolver = new ChainEntityResolver(array(new UuidResolver($entity_manager), new TargetIdResolver())); diff --git a/core/modules/hal/tests/CollectionNormalizerTest.php b/core/modules/hal/tests/CollectionNormalizerTest.php new file mode 100644 index 0000000..2a872a4 --- /dev/null +++ b/core/modules/hal/tests/CollectionNormalizerTest.php @@ -0,0 +1,210 @@ + 'CollectionNormalizer test', + 'description' => "Unit test of the CollectionNormalizer's normalize support.", + 'group' => 'HAL', + ); + } + + /** + * Tests the supportsNormalization method. + */ + public function testSupportsNormalization() { + $collection = $this->getCollection(); + $normalizer = new CollectionNormalizer($this->getLinkManagerStub()); + $this->assertTrue($normalizer->supportsNormalization($collection, 'hal_json')); + $this->assertFalse($normalizer->supportsNormalization($collection, 'json')); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), 'hal_json')); + } + + /** + * Tests the normalize method. + */ + public function testNormalize() { + $test_values = $this->getTestValues(); + $collection = $this->getCollection(); + + // Create the normalizer and inject the LinkManagerStub. + $normalizer = new CollectionNormalizer($this->getLinkManagerStub()); + // Inject the Serializer. Handle the call to Serializer::normalize, + // ensuring that the items array is passed in. + $serializer = $this->getSerializerStub(); + $serializer->expects($this->any()) + ->method('normalize') + ->with($collection->getItems()) + ->will($this->returnValue($test_values['items'])); + $normalizer->setSerializer($serializer); + // Get the normalized array. + $normalized = $normalizer->normalize($collection, 'hal_json'); + + // Test that self link points to collection URI. + $this->assertEquals($normalized['_links']['self']['href'], $test_values['uri']); + // There should only be a self-key. + $this->assertEquals(array_keys($normalized['_links']), array('self')); + + // Test that the correct link relation was retrieved from the LinkManager + // and added to _embedded. + $this->assertArrayHasKey($test_values['item_link_relation'], $normalized['_embedded']); + // Test that the item link relation points to the serialized item array. + $this->assertEquals($normalized['_embedded'][$test_values['item_link_relation']], $test_values['items']); + } + + /** + * Tests the normalize method on pageable collection. + */ + public function testNormalizePageableCollection() { + $test_values = $this->getTestValues(); + $collection = $this->getPageableCollection(); + + // Create the normalizer and inject the LinkManagerStub. + $normalizer = new CollectionNormalizer($this->getLinkManagerStub()); + // Inject the Serializer. Handle the call to Serializer::normalize, + // ensuring that the items array is passed in. + $serializer = $this->getSerializerStub(); + $serializer->expects($this->any()) + ->method('normalize') + ->with($collection->getItems()) + ->will($this->returnValue($test_values['items'])); + $normalizer->setSerializer($serializer); + // Get the normalized array. + $normalized = $normalizer->normalize($collection, 'hal_json'); + + // Test that self link points to collection URI. + $this->assertEquals($normalized['_links']['self']['href'], $test_values['uri']); + // There should be the self-key, _first, _prev, _next and _last-keys. + $this->assertArrayHasKey('self', $normalized['_links']); + $this->assertArrayHasKey('first', $normalized['_links']); + $this->assertArrayHasKey('prev', $normalized['_links']); + $this->assertArrayHasKey('next', $normalized['_links']); + $this->assertArrayHasKey('last', $normalized['_links']); + $this->assertEquals(array_keys($normalized['_links']), + array('self', 'first', 'prev', 'next', 'last') + ); + + // Test that the correct link relation was retrieved from the LinkManager + // and added to _embedded. + $this->assertArrayHasKey($test_values['item_link_relation'], $normalized['_embedded']); + // Test that the item link relation points to the serialized item array. + $this->assertEquals($normalized['_embedded'][$test_values['item_link_relation']], $test_values['items']); + } + + /** + * Get an \Drupal\serialization\Collection for testing. + * + * @return \Drupal\serialization\Collection + * The Collection object, configured with test values. + */ + protected function getCollection() { + $test_values = $this->getTestValues(); + + // Get a dummy node. + $node = $this->getMockBuilder('Drupal\node\Entity\Node') + ->disableOriginalConstructor() + ->getMock(); + + $collection = new Collection('test_id'); + $collection->setUri($test_values['uri']); + $collection->setItems(array($node)); + + return $collection; + } + + /** + * Get an pageable \Drupal\serialization\Collection for testing. + * + * @return \Drupal\serialization\Collection + * The Collection object, configured with test values. + */ + protected function getPageableCollection() { + $test_values = $this->getTestValues(); + + // Get a dummy node. + $node = $this->getMockBuilder('Drupal\node\Entity\Node') + ->disableOriginalConstructor() + ->getMock(); + + $collection = new Collection('test_id'); + $collection->setUri($test_values['uri']); + $collection->setItems(array($node)); + + $collection->setLinks(array( + 'first' => $test_values['uri'] . '?page=0', + 'prev' => $test_values['uri'] . '?page=0', + 'next' => $test_values['uri'] . '?page=2', + 'last' => $test_values['uri'] . '?page=2', + )); + + return $collection; + } + + /** + * Get a stub LinkManager for testing. + * + * @return \PHPUnit_Framework_MockObject_MockObject + * The LinkManager stub. + */ + protected function getLinkManagerStub() { + $test_values = $this->getTestValues(); + + $link_manager = $this->getMockBuilder('Drupal\rest\LinkManager\LinkManager') + ->disableOriginalConstructor() + ->getMock(); + + $link_manager->expects($this->any()) + ->method('getCollectionItemRelation') + ->will($this->returnValue($test_values['item_link_relation'])); + + return $link_manager; + } + + /** + * Get a stub Serializer for testing. + * + * @return \PHPUnit_Framework_MockObject_MockObject + * The Serializer stub. + */ + protected function getSerializerStub() { + $serializer = $this->getMockBuilder('Symfony\Component\Serializer\Serializer') + ->disableOriginalConstructor() + ->getMock(); + + return $serializer; + } + + /** + * Get the array of test values. + * + * @return array + * An array of test values, used for configuring stub methods and testing. + */ + protected function getTestValues() { + return array( + 'item_link_relation' => 'item', + 'items' => 'Array of serialized entities goes here', + 'uri' => 'http://example.com/test-path', + ); + } +} diff --git a/core/modules/rest/rest.services.yml b/core/modules/rest/rest.services.yml index 6172047..c10e03a 100644 --- a/core/modules/rest/rest.services.yml +++ b/core/modules/rest/rest.services.yml @@ -15,7 +15,7 @@ services: - { name: access_check } rest.link_manager: class: Drupal\rest\LinkManager\LinkManager - arguments: ['@rest.link_manager.type', '@rest.link_manager.relation'] + arguments: ['@rest.link_manager.type', '@rest.link_manager.relation', '@rest.link_manager.collection'] rest.link_manager.type: class: Drupal\rest\LinkManager\TypeLinkManager arguments: ['@cache.default'] @@ -27,3 +27,5 @@ services: arguments: ['@plugin.manager.rest', '@config.factory'] tags: - { name: 'event_subscriber' } + rest.link_manager.collection: + class: Drupal\rest\LinkManager\CollectionLinkManager diff --git a/core/modules/rest/src/LinkManager/CollectionLinkManager.php b/core/modules/rest/src/LinkManager/CollectionLinkManager.php new file mode 100644 index 0000000..79c8747 --- /dev/null +++ b/core/modules/rest/src/LinkManager/CollectionLinkManager.php @@ -0,0 +1,23 @@ +typeLinkManager = $type_link_manager; $this->relationLinkManager = $relation_link_manager; + $this->collectionLinkManager = $collection_link_manager; } /** @@ -62,4 +72,11 @@ public function getRelationUri($entity_type, $bundle, $field_name) { public function getRelationInternalIds($relation_uri) { return $this->relationLinkManager->getRelationInternalIds($relation_uri); } + + /** + * {@inheritdoc} + */ + public function getCollectionItemRelation($collection_id) { + return $this->collectionLinkManager->getCollectionItemRelation($collection_id); + } } diff --git a/core/modules/rest/src/LinkManager/LinkManagerInterface.php b/core/modules/rest/src/LinkManager/LinkManagerInterface.php index 60e5629..c9fd834 100644 --- a/core/modules/rest/src/LinkManager/LinkManagerInterface.php +++ b/core/modules/rest/src/LinkManager/LinkManagerInterface.php @@ -16,8 +16,8 @@ * extending all of the component ones. * * While a link manager may directly implement these interface methods with - * custom logic, it is expected to be more common for plugin managers to proxy + * custom logic, it is expected to be more common for link managers to proxy * the method invocations to the respective components. */ -interface LinkManagerInterface extends TypeLinkManagerInterface, RelationLinkManagerInterface { +interface LinkManagerInterface extends TypeLinkManagerInterface, RelationLinkManagerInterface, CollectionLinkManagerInterface { } diff --git a/core/modules/rest/src/Plugin/views/style/Serializer.php b/core/modules/rest/src/Plugin/views/style/Serializer.php index 1403dc3..ae74404 100644 --- a/core/modules/rest/src/Plugin/views/style/Serializer.php +++ b/core/modules/rest/src/Plugin/views/style/Serializer.php @@ -7,6 +7,8 @@ namespace Drupal\rest\Plugin\views\style; +use Drupal\Core\Routing\UrlGeneratorInterface; +use Drupal\serialization\Collection; use Drupal\views\ViewExecutable; use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\Plugin\views\style\StylePluginBase; @@ -52,6 +54,11 @@ class Serializer extends StylePluginBase { protected $formats = array(); /** + * @var \Drupal\Core\Routing\UrlGeneratorInterface + */ + protected $urlGenerator; + + /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { @@ -60,19 +67,21 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_id, $plugin_definition, $container->get('serializer'), - $container->getParameter('serializer.formats') + $container->getParameter('serializer.formats'), + $container->get('url_generator') ); } /** * Constructs a Plugin object. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, SerializerInterface $serializer, array $serializer_formats) { + public function __construct(array $configuration, $plugin_id, array $plugin_definition, SerializerInterface $serializer, array $serializer_formats, UrlGeneratorInterface $url_generator) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->definition = $plugin_definition + $configuration; $this->serializer = $serializer; $this->formats = $serializer_formats; + $this->urlGenerator = $url_generator; } /** @@ -113,17 +122,7 @@ public function submitOptionsForm(&$form, &$form_state) { * {@inheritdoc} */ public function render() { - $rows = array(); - // If the Data Entity row plugin is used, this will be an array of entities - // which will pass through Serializer to one of the registered Normalizers, - // which will transform it to arrays/scalars. If the Data field row plugin - // is used, $rows will not contain objects and will pass directly to the - // Encoder. - foreach ($this->view->result as $row) { - $rows[] = $this->view->rowPlugin->render($row); - } - - return $this->serializer->serialize($rows, $this->displayHandler->getContentType()); + return $this->serializer->serialize($this->getCollection(), $this->displayHandler->getContentType()); } /** @@ -143,4 +142,87 @@ public function getFormats() { return $this->formats; } + /** + * Instantiate Collection object needed to encapsulate serialization. + * + * @return \Drupal\serialization\Collection + * Collection object wrapping items/rows of the view. + */ + public function getCollection() { + $this->view = $this->view; + + $rows = array(); + // @todo Determine how to handle field-based views. + foreach ($this->view->result as $row) { + $rows[] = $this->view->rowPlugin->render($row); + } + + $display = $this->view->getDisplay(); + // Build full view-display id. + $view_id = $this->view->storage->id(); + $display_id = $display->display['id']; + + // Instantiate collection object. + $collection = new Collection($view_id . '_' . $display_id); + + $collection->setTitle($display->getOption('title')); + $collection->setDescription($display->getOption('display_description')); + + // Route as defined in e.g. \Drupal\rest\Plugin\views\display\RestExport. + $route_name = 'view.' . $view_id . '.' . $display_id; + + // Get base url path for the view; getUrl returns a path not an absolute + // URL (and no page information). + $view_base_url = $this->view->getUrl(); + + // Inject the page into the canonical URI of the view. + if ($this->view->getCurrentPage() > 0) { + $uri = $this->urlGenerator->generateFromRoute($route_name, array(), array('query' => array('page' => $this->view->getCurrentPage()), 'absolute' => TRUE)); + } + else { + $uri = $this->urlGenerator->generateFromRoute($route_name, array(), array('absolute' => TRUE)); + } + + $collection->setUri($uri); + $collection->setItems($rows); + + $pager = $this->view->getPager(); + + // Determine whether we have more items than we are showing, in that case + // we are a pageable collection. + if ($pager->getTotalItems() > $pager->getItemsPerPage()) { + // Calculate pager links. + $current_page = $pager->getCurrentPage(); + // Starting at page=0 we need to decrement. + $total = ceil($pager->getTotalItems() / $pager->getItemsPerPage()) - 1; + + $collection->setLink('first', $this->urlGenerator->generateFromRoute($route_name, array(), array( + 'query' => array('page' => 0), + 'absolute' => TRUE, + ))); + + // If we are not on the first page add a previous link. + if ($current_page > 0) { + $collection->setLink('prev', $this->urlGenerator->generateFromRoute($route_name, array(), array( + 'query' => array('page' => $current_page - 1), + 'absolute' => TRUE, + ))); + } + + // If we are not on the last page add a next link. + if ($current_page < $total) { + $collection->setLink('next', $this->urlGenerator->generateFromRoute($route_name, array(), array( + 'query' => array('page' => $current_page + 1), + 'absolute' => TRUE, + ))); + } + + $collection->setLink('last', $this->urlGenerator->generateFromRoute($route_name, array(), array( + 'query' => array('page' => $total), + 'absolute' => TRUE, + ))); + } + + return $collection; + } } diff --git a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php index fa2e954..2df8ae3 100644 --- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php +++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php @@ -8,9 +8,14 @@ namespace Drupal\rest\Tests\Views; use Drupal\Component\Utility\String; +use Drupal\serialization\Collection; +use Drupal\views\ViewExecutable; use Drupal\views\Views; use Drupal\views\Tests\Plugin\PluginTestBase; use Drupal\views\Tests\ViewTestData; +use Drupal\Component\Utility\String; +use Drupal\Component\Utility\Json; +use Drupal\rest\Plugin\views\style\Serializer; /** * Tests the serializer style plugin. @@ -28,26 +33,58 @@ class StyleSerializerTest extends PluginTestBase { * * @var array */ - public static $modules = array('views_ui', 'entity_test', 'hal', 'rest_test_views', 'node', 'text', 'field'); + public static $modules = array( + 'views_ui', + 'entity_test', + 'hal', + 'rest_test_views', + 'node', + 'text', + 'field', + ); /** * Views used by this test. * * @var array */ - public static $testViews = array('test_serializer_display_field', 'test_serializer_display_entity', 'test_serializer_node_display_field'); + public static $testViews = array( + 'test_serializer_display_field', + 'test_serializer_display_entity', + 'test_serializer_node_display_field', + ); /** - * A user with administrative privileges to look at test entity and configure views. + * A user with administrative privileges to look at test entity and configure + * views. */ protected $adminUser; + /** + * {@inheritdoc} + */ + public static function getInfo() { + return array( + 'name' => 'Style: Serializer plugin', + 'description' => 'Tests the serializer style plugin.', + 'group' => 'Views Plugins', + ); + } + + /** + * {@inheritdoc} + */ protected function setUp() { parent::setUp(); ViewTestData::createTestViews(get_class($this), array('rest_test_views')); - $this->adminUser = $this->drupalCreateUser(array('administer views', 'administer entity_test content', 'access user profiles', 'view test entity')); + $this->adminUser = $this->drupalCreateUser(array( + 'administer views', + 'administer entity_test content', + 'access user profiles', + 'view test entity', + )); // Save some entity_test entities. for ($i = 1; $i <= 10; $i++) { @@ -58,6 +95,37 @@ protected function setUp() { } /** + * Retrieves a Drupal path or an absolute path with hal+json. + * + * Sets Accept-Header to application/hal+json and decodes the result. + * + * @param string $path + * Path to request HAL+JSON from. + * @param array $options + * Array of options to pass to url(). + * @param array $headers + * Array of headers. + * + * @return array + * Decoded json. + * Requests a Drupal path in HAL+JSON format, and JSON decodes the response. + */ + protected function drupalGetHalJson($path, array $options = array(), array $headers = array()) { + $headers[] = 'Accept: application/hal+json'; + return Json::decode($this->drupalGet($path, $options, $headers)); + } + + /** + * Retrieve the Collection object for given view. + * + * @return \Drupal\serialization\Collection + * The collection object to pass into the serializer. + */ + protected function getCollectionFromView(ViewExecutable $view) { + return $view->getStyle()->getCollection($view); + } + + /** * Checks the behavior of the Serializer callback paths and row plugins. */ public function testSerializerResponses() { @@ -65,8 +133,8 @@ public function testSerializerResponses() { $view = Views::getView('test_serializer_display_field'); $view->initDisplay(); $this->executeView($view); - - $actual_json = $this->drupalGet('test/serialize/field', array(), array('Accept: application/json')); + // application/json-type serialization. + $actual_json = $this->drupalGetJSON('test/serialize/field'); $this->assertResponse(200); // Test the http Content-type. @@ -82,8 +150,7 @@ public function testSerializerResponses() { $expected[] = $expected_row; } - $this->assertIdentical($actual_json, json_encode($expected), 'The expected JSON output was found.'); - + $this->assertIdentical($actual_json, $expected, 'The expected JSON output was found.'); // Test that the rendered output and the preview output are the same. $view->destroy(); @@ -91,41 +158,146 @@ public function testSerializerResponses() { // Mock the request content type by setting it on the display handler. $view->display_handler->setContentType('json'); $output = $view->preview(); - $this->assertIdentical($actual_json, drupal_render($output), 'The expected JSON preview output was found.'); + $this->assertIdentical(Json::encode($actual_json), drupal_render($output), 'The expected JSON preview output was found.'); // Test a 403 callback. $this->drupalGet('test/serialize/denied'); $this->assertResponse(403); // Test the entity rows. + $view = Views::getView('test_serializer_display_entity'); + $view->setDisplay('rest_export_1'); + $view->initDisplay(); + $this->executeView($view); + // Get the serializer service. + $serializer = $this->container->get('serializer'); + // Create the entity collection. + $collection = $this->getCollectionFromView($view); + $expected = $serializer->serialize($collection, 'json'); + + $this->assertFalse($collection->hasLinks(), 'Collection created from a non-paging view does not have (hypermedia) link relations'); + + $actual_json = $this->drupalGetJSON('test/serialize/entity'); + $this->assertResponse(200); + $this->assertIdentical(Json::encode($actual_json), $expected, 'The expected JSON output was found.'); + + // application/hal+json-type serialization. + $expected = $serializer->serialize($collection, 'hal_json'); + $actual_json = $this->drupalGetHalJson('test/serialize/entity'); + $this->assertIdentical(Json::encode($actual_json), $expected, 'The expected HAL output was found.'); + + // Make assertions on the structure of the response. + $this->assertTrue(isset($actual_json['_embedded']) && isset($actual_json['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json['_embedded']['item']), 10); + $this->assertEqual($actual_json['_links']['self']['href'], url($view->getUrl(), array('absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json['_links']), array('self')); + } + + /** + * Checks the paging behavior of callback paths and row plugins. + */ + protected function testSerializerPageableCollectionHalJsonResponses() { + // Test the entity rows - with paging. $view = Views::getView('test_serializer_display_entity'); + $view->setDisplay('rest_export_paging'); $view->initDisplay(); $this->executeView($view); // Get the serializer service. $serializer = $this->container->get('serializer'); + // Create the entity collection from the current view. + $collection = $this->getCollectionFromView($view); + $this->assertTrue($collection->hasLinks(), 'Collection created from a paging view has (hypermedia) link relations'); + + $expected = $serializer->serialize($collection, 'hal_json'); + $actual_json = $this->drupalGetHalJson('test/serialize/entity_paging'); + $this->assertIdentical(Json::encode($actual_json), $expected, 'The expected HAL output was found.'); + + // Make assertions on the structure of the response. + $this->assertTrue(isset($actual_json['_embedded']) && isset($actual_json['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json['_embedded']['item']), 1); + $this->assertEqual($actual_json['_links']['self']['href'], url($view->getUrl(), array('absolute' => TRUE))); + $this->assertEqual($actual_json['_links']['first']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json['_links']['next']['href'], url($view->getUrl(), array('query' => array('page' => 1), 'absolute' => TRUE))); + $this->assertEqual($actual_json['_links']['last']['href'], url($view->getUrl(), array('query' => array('page' => 9), 'absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json['_links']), array( + 'self', + 'first', + 'next', + 'last', + )); + + // Load the second page. + $actual_json_page_1 = $this->drupalGetHalJson($actual_json['_links']['next']['href']); + $this->assertTrue(isset($actual_json_page_1['_embedded']) && isset($actual_json_page_1['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json_page_1['_embedded']['item']), 1); + $this->assertEqual($actual_json_page_1['_links']['self']['href'], url($view->getUrl(), array('query' => array('page' => 1), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['first']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['prev']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['next']['href'], url($view->getUrl(), array('query' => array('page' => 2), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['last']['href'], url($view->getUrl(), array('query' => array('page' => 9), 'absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json_page_1['_links']), array( + 'self', + 'first', + 'prev', + 'next', + 'last', + )); + + // Test the entity rows - with paging. + $view = Views::getView('test_serializer_display_entity'); + $view->setDisplay('rest_export_paging'); + $view->initDisplay(); + $view->setCurrentPage(1); + $this->executeView($view); - $entities = array(); - foreach ($view->result as $row) { - $entities[] = $row->_entity; - } + // Create the entity collection. + $collection = $this->getCollectionFromView($view); + $this->assertTrue($collection->hasLinks(), 'Collection created from a paging view has (hypermedia) link relations'); + $expected = $serializer->serialize($collection, 'hal_json'); - $expected = $serializer->serialize($entities, 'json'); + $this->assertEqual(Json::encode($actual_json_page_1), $expected, 'The expected HAL output for page=1 was found.'); - $actual_json = $this->drupalGet('test/serialize/entity', array(), array('Accept: application/json')); - $this->assertResponse(200); - $this->assertIdentical($actual_json, $expected, 'The expected JSON output was found.'); + // Load the last page. + $actual_json_page_last = $this->drupalGetHalJSON($actual_json['_links']['last']['href']); - $expected = $serializer->serialize($entities, 'hal_json'); - $actual_json = $this->drupalGet('test/serialize/entity', array(), array('Accept: application/hal+json')); - $this->assertIdentical($actual_json, $expected, 'The expected HAL output was found.'); + $this->assertTrue(isset($actual_json_page_last['_embedded']) && isset($actual_json_page_last['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json_page_last['_embedded']['item']), 1); + $this->assertEqual($actual_json_page_last['_links']['self']['href'], url($view->getUrl(), array('query' => array('page' => 9), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_last['_links']['first']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_last['_links']['prev']['href'], url($view->getUrl(), array('query' => array('page' => 8), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_last['_links']['last']['href'], url($view->getUrl(), array('query' => array('page' => 9), 'absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json_page_last['_links']), array( + 'self', + 'first', + 'prev', + 'last', + )); + + // Test the entity rows - with paging. + $view = Views::getView('test_serializer_display_entity'); + $view->setDisplay('rest_export_paging'); + $view->initDisplay(); + $view->setCurrentPage(9); + $this->executeView($view); + + // Create the entity collection. + $collection = $this->getCollectionFromView($view); + $this->assertTrue($collection->hasLinks(), 'Collection created from a paging view has (hypermedia) link relations'); + $expected = $serializer->serialize($collection, 'hal_json'); + + $this->assertEqual(Json::encode($actual_json_page_last), $expected, 'The expected HAL output for last page was found.'); } /** * Tests the response format configuration. */ - public function testReponseFormatConfiguration() { + public function testResponseFormatConfiguration() { $this->drupalLogin($this->adminUser); $style_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/style_options'; @@ -137,7 +309,7 @@ public function testReponseFormatConfiguration() { // Should return a 406. $this->drupalGet('test/serialize/field', array(), array('Accept: application/json')); $this->assertResponse(406, 'A 406 response was returned when JSON was requested.'); - // Should return a 200. + // Should return a 200. $this->drupalGet('test/serialize/field', array(), array('Accept: application/xml')); $this->assertResponse(200, 'A 200 response was returned when XML was requested.'); @@ -232,6 +404,148 @@ public function testUIFieldAlias() { $this->assertIdentical($this->drupalGetJSON('test/serialize/field'), $expected); } + + /** + * Tests the Serializer paths and responses for field-based views. + */ + public function testSerializerFieldDisplayResponse() { + $view = Views::getView('test_serializer_display_field'); + $view->setDisplay('rest_export_1'); + // Mock the request content type by setting it on the display handler. + $view->display_handler->setContentType('hal_json'); + $this->executeView($view); + + $view_output = $view->preview(); + $view_result = array(); + foreach ($view->result as $row) { + $expected_row = array(); + foreach ($view->field as $id => $field) { + $expected_row[$id] = $field->render($row); + } + $view_result[] = $expected_row; + } + + $serializer = $this->container->get('serializer'); + $collection = $this->getCollectionFromView($view); + $expected = $serializer->serialize($collection, 'hal_json'); + + $actual_json = $this->drupalGetHalJson('test/serialize/field'); + + $this->assertIdentical(Json::encode($actual_json), drupal_render($view_output), 'Preview output matches the (reserialized) JSON returned from the view via HTTP GET.'); + $this->assertIdentical(Json::encode($actual_json), $expected, 'HAL serializer output matches the (reserialized) JSON returned from the view via HTTP GET.'); + $this->assertIdentical($actual_json['_embedded']['item'], $view_result, 'View result matches JSON returned from the view via HTTP GET'); + } + + /** + * Tests the Serializer paths and responses for field-based views with paging. + */ + public function testSerializerFieldDisplayPagingResponse() { + $view = Views::getView('test_serializer_display_field'); + $view->setDisplay('rest_export_paging'); + // Mock the request content type by setting it on the display handler. + $view->display_handler->setContentType('hal_json'); + $this->executeView($view); + + $view_output = $view->preview(); + $view_result = array(); + foreach ($view->result as $row) { + $expected_row = array(); + foreach ($view->field as $id => $field) { + $expected_row[$id] = $field->render($row); + } + $view_result[] = $expected_row; + } + + $serializer = $this->container->get('serializer'); + $collection = $this->getCollectionFromView($view); + $expected = $serializer->serialize($collection, 'hal_json'); + + $actual_json = $this->drupalGetHalJson('test/serialize/field-paging'); + + $this->assertIdentical(Json::encode($actual_json), drupal_render($view_output), 'Preview output matches the (reserialized) JSON returned from the view via HTTP GET.'); + $this->assertIdentical(Json::encode($actual_json), $expected, 'HAL serializer output matches the (reserialized) JSON returned from the view via HTTP GET.'); + $this->assertIdentical($actual_json['_embedded']['item'], $view_result, 'View result matches JSON returned from the view via HTTP GET'); + + // Make assertions on the structure of the response. + $this->assertTrue(isset($actual_json['_embedded']) && isset($actual_json['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json['_embedded']['item']), 1); + $this->assertEqual($actual_json['_links']['self']['href'], url($view->getUrl(), array('absolute' => TRUE))); + $this->assertEqual($actual_json['_links']['first']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json['_links']['next']['href'], url($view->getUrl(), array('query' => array('page' => 1), 'absolute' => TRUE))); + $this->assertEqual($actual_json['_links']['last']['href'], url($view->getUrl(), array('query' => array('page' => 4), 'absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json['_links']), array( + 'self', + 'first', + 'next', + 'last', + )); + + // Load the second page. + $actual_json_page_1 = $this->drupalGetHalJson($actual_json['_links']['next']['href']); + + $this->assertTrue(isset($actual_json_page_1['_embedded']) && isset($actual_json_page_1['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json_page_1['_embedded']['item']), 1); + $this->assertEqual($actual_json_page_1['_links']['self']['href'], url($view->getUrl(), array('query' => array('page' => 1), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['first']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['prev']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['next']['href'], url($view->getUrl(), array('query' => array('page' => 2), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_1['_links']['last']['href'], url($view->getUrl(), array('query' => array('page' => 4), 'absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json_page_1['_links']), array( + 'self', + 'first', + 'prev', + 'next', + 'last', + )); + + // Test the entity rows - with paging. + $view = Views::getView('test_serializer_display_field'); + $view->setDisplay('rest_export_paging'); + $view->initDisplay(); + $view->setCurrentPage(1); + $this->executeView($view); + + // Create the entity collection. + $collection = $this->getCollectionFromView($view); + $this->assertTrue($collection->hasLinks(), 'Collection created from a paging view has (hypermedia) link relations'); + $expected = $serializer->serialize($collection, 'hal_json'); + + $this->assertEqual(Json::encode($actual_json_page_1), $expected, 'The expected HAL output for page=1 was found.'); + + // Load the last page. + $actual_json_page_last = $this->drupalGetHalJson($actual_json['_links']['last']['href']); + + $this->assertTrue(isset($actual_json_page_last['_embedded']) && isset($actual_json_page_last['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json_page_last['_embedded']['item']), 1); + $this->assertEqual($actual_json_page_last['_links']['self']['href'], url($view->getUrl(), array('query' => array('page' => 4), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_last['_links']['first']['href'], url($view->getUrl(), array('query' => array('page' => 0), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_last['_links']['prev']['href'], url($view->getUrl(), array('query' => array('page' => 3), 'absolute' => TRUE))); + $this->assertEqual($actual_json_page_last['_links']['last']['href'], url($view->getUrl(), array('query' => array('page' => 4), 'absolute' => TRUE))); + $this->assertEqual(array_keys($actual_json_page_last['_links']), array( + 'self', + 'first', + 'prev', + 'last', + )); + + // Test the entity rows - with paging. + $view = Views::getView('test_serializer_display_field'); + $view->setDisplay('rest_export_paging'); + $view->initDisplay(); + $view->setCurrentPage(4); + $this->executeView($view); + + // Create the entity collection. + $collection = $this->getCollectionFromView($view); + $this->assertTrue($collection->hasLinks(), 'Collection created from a paging view has (hypermedia) link relations'); + $expected = $serializer->serialize($collection, 'hal_json'); + + $this->assertEqual(Json::encode($actual_json_page_last), $expected, 'The expected HAL output for last page was found.'); + } + /** * Tests the raw output options for row field rendering. */ @@ -268,13 +582,9 @@ public function testPreview() { // Get the serializer service. $serializer = $this->container->get('serializer'); - - $entities = array(); - foreach ($view->result as $row) { - $entities[] = $row->_entity; - } - - $expected = String::checkPlain($serializer->serialize($entities, 'json')); + // Create the collection. + $collection = $this->getCollectionFromView($view); + $expected = String::checkPlain($serializer->serialize($collection, 'json')); $view->display_handler->setContentType('json'); $view->live_preview = TRUE; @@ -294,6 +604,8 @@ public function testFieldapiField() { $result = $this->drupalGetJSON('test/serialize/node-field'); $this->assertEqual($result[0]['nid'], $node->id()); $this->assertEqual($result[0]['body'], $node->body->processed); + + // @todo: add HAL+JSON and paging. } } diff --git a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_entity.yml b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_entity.yml index 98ec072..9fb32e6 100644 --- a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_entity.yml +++ b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_entity.yml @@ -44,6 +44,21 @@ display: defaults: access: false path: test/serialize/entity + rest_export_paging: + display_plugin: rest_export + id: rest_export_paging + display_title: serializer + position: null + display_options: + defaults: + access: false + path: test/serialize/entity_paging + pager: + type: full + options: + items_per_page: 1 + offset: 0 + id: 0 base_field: id status: true module: rest diff --git a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_field.yml b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_field.yml index 33f6c94..866b085 100644 --- a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_field.yml +++ b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_display_field.yml @@ -99,6 +99,29 @@ display: type: serializer row: type: data_field + rest_export_paging: + display_plugin: rest_export + id: rest_export_paging + display_title: 'serializer - paging' + position: null + display_options: + defaults: + access: false + style: false + row: false + path: test/serialize/field-paging + access: + type: none + style: + type: serializer + row: + type: data_field + pager: + type: full + options: + items_per_page: 1 + offset: 0 + id: 0 base_field: id status: true module: rest diff --git a/core/modules/serialization/src/Collection.php b/core/modules/serialization/src/Collection.php new file mode 100644 index 0000000..e837b87 --- /dev/null +++ b/core/modules/serialization/src/Collection.php @@ -0,0 +1,214 @@ +id = $collection_id; + } + + /** + * Get the internal ID of the collection. + */ + public function getCollectionId() { + return $this->id; + } + + /** + * Get the items in the collection. + * + * @return array + * The items of the collection. + */ + public function getItems() { + return $this->items; + } + + /** + * Set the items list. + * + * @param array $items + * The items of the collection. + */ + public function setItems($items) { + $this->items = $items; + } + + /** + * Get the collection title. + * + * @return string + * The title of the collection. + */ + public function getTitle() { + return $this->title; + } + + /** + * Set the collection title. + * + * @param string $title + * The items of the collection. + */ + public function setTitle($title) { + $this->title = $title; + } + + /** + * Get the collection URI. + * + * @return string + * The URI ("self") of the collection. + */ + public function getUri() { + return $this->uri; + } + + /** + * Set the collection URI. + * + * @param string $uri + * The URI ("self") of the collection. + */ + public function setUri($uri) { + $this->uri = $uri; + } + + /** + * Get the collection description. + * + * @return string + * The description of the collection. + */ + public function getDescription() { + return $this->description; + } + + /** + * Set the collection URI. + * + * @param string $description + * The description of the collection. + */ + public function setDescription($description) { + $this->description = $description; + } + + /** + * {@inheritdoc} + */ + public function getIterator() { + return new \ArrayIterator($this->getItems()); + } + + /** + * Sets a link URI for a given type. + * + * @param string $type + * The link relation type. + * @param string $uri + * The link relation URI. + */ + public function setLink($type, $uri) { + $this->links[$type] = $uri; + } + + /** + * Gets a link URI for a given type. + * + * @param string $type + * The link relation type. + * + * @return NULL|String + * The URI of the link relation type or NULL. + */ + public function getLink($type) { + return isset($this->links[$type]) ? $this->links[$type] : NULL; + } + + /** + * Sets all link URIs. + * + * @param array $links + * Associative array of link relation types and URIs. + */ + public function setLinks($links) { + $this->links = $links; + } + + /** + * Gets all link URIs. + * + * @return array + * Associative array of link relation types and URIs. + */ + public function getLinks() { + return $this->links; + } + + /** + * Returns true if (hypermedia) link relations have been added. + * + * @return bool + * True if link relations have been added. + */ + public function hasLinks() { + return is_array($this->links) && count($this->links); + } +} diff --git a/core/modules/serialization/tests/Drupal/serialization/Tests/CollectionTest.php b/core/modules/serialization/tests/Drupal/serialization/Tests/CollectionTest.php new file mode 100644 index 0000000..7d8f0aa --- /dev/null +++ b/core/modules/serialization/tests/Drupal/serialization/Tests/CollectionTest.php @@ -0,0 +1,113 @@ + 'CollectionTest', + 'description' => 'Tests the Collection class used for serializing collections.', + 'group' => 'Serialization', + ); + } + + /** + * Tests the constructor, as well as getters and setters. + * + * @covers ::__construct + * @covers ::getTitle + * @covers ::setTitle + * @covers ::getURI + * @covers ::setURI + * @covers ::getDescription + * @covers ::setDescription + */ + public function testConstructor() { + $collection_id = $this->randomName(); + $collection = new Collection($collection_id); + $this->assertSame($collection_id, $collection->getCollectionId(), 'Id has been set accordingly'); + + $this->assertEquals($collection->getTitle(), NULL, 'Collection title is not set'); + $collection->setTitle($collection_id); + $this->assertEquals($collection->getTitle(), $collection_id, 'Collection title has been set'); + + $this->assertEquals($collection->getUri(), NULL, 'Collection URI is not set'); + $collection->setURI('http://example.com/' . $collection_id); + $this->assertEquals($collection->getUri(), 'http://example.com/' . $collection_id, 'Collection URI has been set'); + + $this->assertEquals($collection->getDescription(), NULL, 'Collection description is not set'); + $collection->setDescription($collection_id); + $this->assertEquals($collection->getDescription(), $collection_id, 'Collection description has been set'); + } + + /** + * Tests items setter and getter as well as iteration. + * + * @covers ::setItems + * @covers ::getItems + * @covers ::getIterator + */ + public function testSettingItemsAndIterating() { + $collection = new Collection('example'); + + $this->assertEquals($collection->getItems(), NULL, 'By default the items are NULL'); + + $example = array(1, 2, 3); + $collection->setItems($example); + $this->assertEquals($collection->getItems(), $example, 'Setters and getters work on the Collection'); + + // Iterating over collection returns the same array. + $array = array(); + foreach ($collection as $k => $v) { + $array[$k] = $v; + } + $this->assertEquals($array, $collection->getItems(), 'Array filled via iteration matches the array of the getter'); + } + + /** + * Tests setters and getters for link relations. + * + * @covers ::setLinks + * @covers ::getLinks + * @covers ::hasLinks + * @covers ::getLink + * @covers ::setLink + */ + public function testSetGetHasLinks() { + $collection = new Collection('example'); + $this->assertFalse($collection->hasLinks(), 'By default a Collection does not have any link relations'); + + $collection->setURI('http://example.com/collection'); + $this->assertFalse($collection->hasLinks(), 'Setting the main URI (self) of the collection does not count as a link relation'); + + $this->assertEquals($collection->getLink('next'), NULL, 'Before setting a link for a type the getter returns NULL'); + $collection->setLink('next', 'http://example.com/collection/?page=1'); + $this->assertEquals($collection->getLink('next'), 'http://example.com/collection/?page=1', 'After setting a link for a type the getter returns correctly'); + $this->assertTrue($collection->hasLinks(), 'Setting another URI (self) of the collection does not count as a link relation'); + $this->assertEquals($collection->getLinks(), array('next' => 'http://example.com/collection/?page=1')); + + $collection->setLinks(array()); + $this->assertFalse($collection->hasLinks(), 'After resetting links to an empty array, hasLinks should return false'); + + $collection->setLinks(NULL); + $this->assertFalse($collection->hasLinks(), 'After resetting links to NULL, hasLinks should return false'); + } +}