diff --git a/core/modules/comment/src/Tests/Views/CommentRestExportTest.php b/core/modules/comment/src/Tests/Views/CommentRestExportTest.php index a3d7e2d..31d4fac 100644 --- a/core/modules/comment/src/Tests/Views/CommentRestExportTest.php +++ b/core/modules/comment/src/Tests/Views/CommentRestExportTest.php @@ -54,8 +54,8 @@ public function testCommentRestExport() { $this->drupalGetWithFormat(sprintf('node/%d/comments', $this->nodeUserCommented->id()), 'hal_json'); $this->assertResponse(200); $contents = Json::decode($this->getRawContent()); - $this->assertEqual($contents[0]['subject'], 'How much wood would a woodchuck chuck'); - $this->assertEqual($contents[1]['subject'], 'A lot, apparently'); + $this->assertEqual($contents['_embedded']['item'][0]['subject'], 'How much wood would a woodchuck chuck'); + $this->assertEqual($contents['_embedded']['item'][1]['subject'], 'A lot, apparently'); $this->assertEqual(count($contents), 2); // Ensure field-level access is respected - user shouldn't be able to see diff --git a/core/modules/hal/hal.services.yml b/core/modules/hal/hal.services.yml index e817fbf..03b66ef 100644 --- a/core/modules/hal/hal.services.yml +++ b/core/modules/hal/hal.services.yml @@ -30,3 +30,8 @@ services: class: Drupal\hal\EventSubscriber\ExceptionHalJsonSubscriber tags: - { name: event_subscriber } + serializer.normalizer.collection.hal: + class: Drupal\hal\Normalizer\CollectionNormalizer + arguments: ['@rest.link_manager'] + tags: + - { name: normalizer, priority: 10 } diff --git a/core/modules/hal/src/Normalizer/CollectionNormalizer.php b/core/modules/hal/src/Normalizer/CollectionNormalizer.php new file mode 100644 index 0000000..c66cc64 --- /dev/null +++ b/core/modules/hal/src/Normalizer/CollectionNormalizer.php @@ -0,0 +1,92 @@ +linkManager = $link_manager; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = NULL, array $context = []) { + // Create the array of normalized properties, starting with the URI. + $normalized = [ + '_links' => [ + 'self' => [ + 'href' => $object->getUri(), + ], + ], + ]; + + // $object is not type-hinted, so we cannot be sure this method will not be + // called on a non-Collection object, and there is no interface to check on. + if (!$object instanceof Collection) { + return $normalized; + } + + // 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] = ['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 = []) { + } + + /** + * {@inheritdoc} + * + * @todo Implement denormalization once normalization has settled. + */ + public function supportsDenormalization($data, $type, $format = NULL) { + return FALSE; + } +} diff --git a/core/modules/hal/tests/src/Kernel/FileNormalizeTest.php b/core/modules/hal/tests/src/Kernel/FileNormalizeTest.php index 2aeba89..68f9be9 100644 --- a/core/modules/hal/tests/src/Kernel/FileNormalizeTest.php +++ b/core/modules/hal/tests/src/Kernel/FileNormalizeTest.php @@ -11,6 +11,7 @@ use Drupal\rest\LinkManager\RelationLinkManager; use Drupal\rest\LinkManager\TypeLinkManager; use Symfony\Component\Serializer\Serializer; +use Drupal\rest\LinkManager\CollectionLinkManager; /** @@ -35,7 +36,7 @@ protected function setUp() { $this->installEntitySchema('file'); $entity_manager = \Drupal::entityManager(); - $link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend('default'), \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')), new RelationLinkManager(new MemoryBackend('default'), $entity_manager, \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack'))); + $link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend('default'), \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')), new RelationLinkManager(new MemoryBackend('default'), $entity_manager, \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')), new CollectionLinkManager()); // Set up the mock serializer. $normalizers = array( diff --git a/core/modules/hal/tests/src/Kernel/NormalizerTestBase.php b/core/modules/hal/tests/src/Kernel/NormalizerTestBase.php index d2899d0..1f618ff 100644 --- a/core/modules/hal/tests/src/Kernel/NormalizerTestBase.php +++ b/core/modules/hal/tests/src/Kernel/NormalizerTestBase.php @@ -10,6 +10,7 @@ use Drupal\hal\Normalizer\FieldItemNormalizer; use Drupal\hal\Normalizer\FieldNormalizer; use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\rest\LinkManager\CollectionLinkManager; use Drupal\rest\LinkManager\LinkManager; use Drupal\rest\LinkManager\RelationLinkManager; use Drupal\rest\LinkManager\TypeLinkManager; @@ -131,7 +132,10 @@ protected function setUp() { ])->save(); $entity_manager = \Drupal::entityManager(); - $link_manager = new LinkManager(new TypeLinkManager(new MemoryBackend('default'), \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')), new RelationLinkManager(new MemoryBackend('default'), $entity_manager, \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack'))); + $link_manager = new LinkManager( + new TypeLinkManager(new MemoryBackend('default'), \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')), + new RelationLinkManager(new MemoryBackend('default'), $entity_manager, \Drupal::moduleHandler(), \Drupal::service('config.factory'), \Drupal::service('request_stack')), + new CollectionLinkManager()); $chain_resolver = new ChainEntityResolver(array(new UuidResolver($entity_manager), new TargetIdResolver())); diff --git a/core/modules/hal/tests/src/Unit/CollectionNormalizerTest.php b/core/modules/hal/tests/src/Unit/CollectionNormalizerTest.php new file mode 100644 index 0000000..7953815 --- /dev/null +++ b/core/modules/hal/tests/src/Unit/CollectionNormalizerTest.php @@ -0,0 +1,199 @@ +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 mock 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 \Drupal\rest\LinkManager\LinkManagerInterface + * 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 \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Normalizer\NormalizerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface|\Symfony\Component\Serializer\Encoder\EncoderInterface|\Symfony\Component\Serializer\Encoder\DecoderInterface|\PHPUnit_Framework_MockObject_MockObject + * The Serializer mock. + */ + 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 6b613e3..d8caa26 100644 --- a/core/modules/rest/rest.services.yml +++ b/core/modules/rest/rest.services.yml @@ -15,13 +15,15 @@ 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', '@module_handler', '@config.factory', '@request_stack'] rest.link_manager.relation: class: Drupal\rest\LinkManager\RelationLinkManager arguments: ['@cache.default', '@entity.manager', '@module_handler', '@config.factory', '@request_stack'] + rest.link_manager.collection: + class: Drupal\rest\LinkManager\CollectionLinkManager rest.resource_routes: class: Drupal\rest\Routing\ResourceRoutes arguments: ['@plugin.manager.rest', '@config.factory', '@logger.channel.rest'] diff --git a/core/modules/rest/src/LinkManager/CollectionLinkManager.php b/core/modules/rest/src/LinkManager/CollectionLinkManager.php new file mode 100644 index 0000000..2175a0c --- /dev/null +++ b/core/modules/rest/src/LinkManager/CollectionLinkManager.php @@ -0,0 +1,25 @@ +typeLinkManager = $type_link_manager; $this->relationLinkManager = $relation_link_manager; + $this->collectionLinkManager = $collection_link_manager; } /** @@ -68,4 +78,11 @@ public function setLinkDomain($domain) { return $this; } + /** + * {@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 1bd04ff..92b9a25 100644 --- a/core/modules/rest/src/LinkManager/LinkManagerInterface.php +++ b/core/modules/rest/src/LinkManager/LinkManagerInterface.php @@ -14,5 +14,5 @@ * custom logic, it is expected to be more common for plugin 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/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php index fb58513..30d31c2 100644 --- a/core/modules/rest/src/Plugin/views/display/RestExport.php +++ b/core/modules/rest/src/Plugin/views/display/RestExport.php @@ -251,7 +251,7 @@ public function optionsSummary(&$categories, &$options) { * {@inheritdoc} */ public function collectRoutes(RouteCollection $collection) { - parent::collectRoutes($collection); + $result = parent::collectRoutes($collection); $view_id = $this->view->storage->id(); $display_id = $this->display['id']; @@ -269,6 +269,7 @@ public function collectRoutes(RouteCollection $collection) { $route->setRequirement('_format', implode('|', $formats + ['html'])); } } + return $result; } /** diff --git a/core/modules/rest/src/Plugin/views/style/Serializer.php b/core/modules/rest/src/Plugin/views/style/Serializer.php index 71bb511..227739b 100644 --- a/core/modules/rest/src/Plugin/views/style/Serializer.php +++ b/core/modules/rest/src/Plugin/views/style/Serializer.php @@ -8,6 +8,9 @@ use Drupal\views\Plugin\views\style\StylePluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Serializer\SerializerInterface; +use Drupal\Core\Routing\UrlGeneratorInterface; +use Drupal\Core\State\StateInterface; +use Drupal\serialization\Collection; /** * The style plugin for serialized output formats. @@ -45,7 +48,21 @@ class Serializer extends StylePluginBase implements CacheableDependencyInterface * * @var array */ - protected $formats = array(); + protected $formats = []; + + /** + * The URL generator service. + * + * @var \Drupal\Core\Routing\UrlGeneratorInterface + */ + protected $urlGenerator; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; /** * {@inheritdoc} @@ -56,19 +73,38 @@ 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'), + $container->get('state') ); } /** - * Constructs a Plugin object. + * Serializer constructor. + * + * @param array $configuration + * + * @param string $plugin_id + * + * @param array $plugin_definition + * + * @param \Symfony\Component\Serializer\SerializerInterface $serializer + * + * @param array $serializer_formats + * + * @param UrlGeneratorInterface $url_generator + * The URL generator service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. */ - 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, StateInterface $state) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->definition = $plugin_definition + $configuration; $this->serializer = $serializer; $this->formats = $serializer_formats; + $this->urlGenerator = $url_generator; + $this->state = $state; } /** @@ -130,7 +166,7 @@ public function render() { else { $content_type = !empty($this->options['formats']) ? reset($this->options['formats']) : 'json'; } - return $this->serializer->serialize($rows, $content_type, ['views_style_plugin' => $this]); + return $this->serializer->serialize($this->getCollection(), $content_type, ['views_style_plugin' => $this]); } /** @@ -167,4 +203,97 @@ public function getCacheTags() { return []; } + /** + * Instantiate Collection object needed to encapsulate serialization. + * + * @return \Drupal\serialization\Collection + * Collection object wrapping items/rows of the view. + */ + public function getCollection() { + + $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($this->view->getTitle()); + $collection->setDescription($display->getOption('display_description')); + + // Route as defined in e.g. \Drupal\rest\Plugin\views\display\RestExport. + $route_names = $this->state->get('views.view_route_names'); + $route_name = $route_names["$view_id.$display_id"]; + + // Get base url path for the view; getUrl returns a path not an absolute + // URL (and no page information). + /** @var \Drupal\Core\Url $view_base_url */ + $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, $view_base_url->getRouteParameters(), array('query' => array('page' => $this->view->getCurrentPage()), 'absolute' => TRUE)); + } + else { + $uri = $this->urlGenerator->generateFromRoute($route_name, $view_base_url->getRouteParameters(), array('absolute' => TRUE)); + } + $collection->setUri($uri); + + $rows = []; + foreach ($this->view->result as $row_index => $row) { + $this->view->row_index = $row_index; + $rows[] = $this->view->rowPlugin->render($row); + } + + unset($this->view->row_index); + $collection->setItems($rows); + + $pager = $this->view->getPager(); + $pager_plugin = $pager->getPluginId(); + + // 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; + // The total number of page for mini pager generates a big float number. + $total = number_format($total, 0, NULL, ''); + + if ($pager_plugin != 'mini') { + $collection->setLink('first', $this->urlGenerator->generateFromRoute($route_name, [], 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( + '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( + 'query' => array('page' => $current_page + 1), + 'absolute' => TRUE, + ))); + } + + if ($pager_plugin != 'mini') { + $collection->setLink('last', $this->urlGenerator->generateFromRoute($route_name, [], 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 a884342..9cdaaf1 100644 --- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php +++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php @@ -2,8 +2,10 @@ namespace Drupal\rest\Tests\Views; +use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\Cache; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\Url; use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; @@ -11,6 +13,7 @@ use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait; use Drupal\views\Entity\View; use Drupal\views\Plugin\views\display\DisplayPluginBase; +use Drupal\views\ViewExecutable; use Drupal\views\Views; use Drupal\views\Tests\Plugin\PluginTestBase; use Drupal\views\Tests\ViewTestData; @@ -69,6 +72,182 @@ 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 the URL generator. + * @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. + * + * @param \Drupal\views\ViewExecutable $view + * The View being executed. + * + * @return \Drupal\serialization\Collection + * The collection object to pass into the serializer. + */ + protected function getCollectionFromView(ViewExecutable $view) { + return $view->getStyle()->getCollection(); + } + + + + /** + * 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->drupalGetWithFormat('test/serialize/field', 'hal_json'); + + $this->assertIdentical($actual_json, $view_output['#markup']->jsonSerialize(), 'Preview output matches the (reserialized) JSON returned from the view via HTTP GET.'); + $this->assertIdentical($actual_json, $expected, 'HAL serializer output matches the (reserialized) JSON returned from the view via HTTP GET.'); + // @TODO needs review. the structure is not the same at all. + //$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->drupalGetWithFormat('test/serialize/field-paging', 'hal_json'); + $actual_json_decoded = Json::decode($actual_json); + $this->assertIdentical($actual_json, $view_output['#markup']->jsonSerialize(), 'Preview output matches the (reserialized) JSON returned from the view via HTTP GET.'); + $this->assertIdentical($actual_json, $expected, 'HAL serializer output matches the (reserialized) JSON returned from the view via HTTP GET.'); + // @TODO Remove? the structure is not the same at all. + //$this->assertIdentical(Json::decode($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_decoded['_embedded']) && isset($actual_json_decoded['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json_decoded['_embedded']['item']), 1); + $this->assertEqual($actual_json_decoded['_links']['self']['href'], $this->viewUrl($view)); + $this->assertEqual($actual_json_decoded['_links']['first']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_decoded['_links']['next']['href'], $this->viewUrl($view, 1)); + $this->assertEqual($actual_json_decoded['_links']['last']['href'], $this->viewUrl($view, 4)); + $this->assertEqual(array_keys($actual_json_decoded['_links']), array( + 'self', + 'first', + 'next', + 'last', + )); + + // Load the second page. + $actual_json_page_1 = $this->drupalGetHalJson($actual_json_decoded['_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'], $this->viewUrl($view, 1)); + $this->assertEqual($actual_json_page_1['_links']['first']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_page_1['_links']['prev']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_page_1['_links']['next']['href'], $this->viewUrl($view, 2)); + $this->assertEqual($actual_json_page_1['_links']['last']['href'], $this->viewUrl($view, 4)); + $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->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->drupalGetWithFormat($actual_json_decoded['_links']['last']['href'], 'hal_json');//$this->drupalGetHalJson($actual_json_decoded['_links']['last']['href']); + $actual_json_page_last_decoded = Json::decode($actual_json_page_last); + $this->assertTrue(isset($actual_json_page_last_decoded['_embedded']) && isset($actual_json_page_last_decoded['_links']), 'Has _links and _embedded keys'); + + $this->assertEqual(count($actual_json_page_last_decoded['_embedded']['item']), 1); + $this->assertEqual($actual_json_page_last_decoded['_links']['self']['href'], $this->viewUrl($view, 4)); + $this->assertEqual($actual_json_page_last_decoded['_links']['first']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_page_last_decoded['_links']['prev']['href'], $this->viewUrl($view, 3)); + $this->assertEqual($actual_json_page_last_decoded['_links']['last']['href'], $this->viewUrl($view, 4)); + $this->assertEqual(array_keys($actual_json_page_last_decoded['_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->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($actual_json_page_last, $expected, 'The expected HAL output for last page was found.'); + } + /** * Checks the behavior of the Serializer callback paths and row plugins. */ public function testSerializerResponses() { @@ -114,7 +293,7 @@ public function testSerializerResponses() { // Test the entity rows. $view = Views::getView('test_serializer_display_entity'); - $view->initDisplay(); + $view->setDisplay('rest_export_1'); $this->executeView($view); // Get the serializer service. @@ -139,11 +318,22 @@ public function testSerializerResponses() { $this->assertCacheTags($expected_cache_tags); $this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']); - $expected = $serializer->serialize($entities, 'hal_json'); + // Create the entity collection. + $collection = $this->getCollectionFromView($view); + $expected = $serializer->serialize($collection, 'hal_json'); $actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'hal_json'); $this->assertIdentical($actual_json, $expected, 'The expected HAL output was found.'); $this->assertCacheTags($expected_cache_tags); + // Make assertions on the structure of the response. + $actual_json_decoded = JSON::decode($actual_json); + $this->assertTrue(isset($actual_json_decoded['_embedded']), 'Has _embedded key.'); + $this->assertTrue(isset($actual_json_decoded['_links']), 'Has _links key.'); + + $this->assertEqual(count($actual_json_decoded['_embedded']['item']), 10); + $this->assertEqual($actual_json_decoded['_links']['self']['href'], $this->viewUrl($view)); + $this->assertEqual(array_keys($actual_json_decoded['_links']), ['self']); + // Change the default format to xml. $view->setDisplay('rest_export_1'); $view->getDisplay()->setOption('style', array( @@ -183,6 +373,110 @@ public function testSerializerResponses() { } /** + * Build an absolute URL from a view path, accounting for paging. + * + * @param ViewExecutable $view + * The View being executed. + * @param null $page + * + * @return string + */ + protected function viewUrl(ViewExecutable $view, $page = NULL) { + $base_url = 'base://' . $view->getPath(); + $options = array( + 'absolute' => TRUE, + ); + if (isset($page)) { + $options += array('query' => array('page' => $page)); + } + return Url::fromUri($base_url, $options)->toString(); + } + + /** + * 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'); + $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'], $this->viewUrl($view)); + $this->assertEqual($actual_json['_links']['first']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json['_links']['next']['href'], $this->viewUrl($view, 1)); + $this->assertEqual($actual_json['_links']['last']['href'], $this->viewUrl($view, 9)); + $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'], $this->viewUrl($view, 1)); + $this->assertEqual($actual_json_page_1['_links']['first']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_page_1['_links']['prev']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_page_1['_links']['next']['href'], $this->viewUrl($view, 2)); + $this->assertEqual($actual_json_page_1['_links']['last']['href'], $this->viewUrl($view, 9)); + $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->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'], $this->viewUrl($view, 9)); + $this->assertEqual($actual_json_page_last['_links']['first']['href'], $this->viewUrl($view, 0)); + $this->assertEqual($actual_json_page_last['_links']['prev']['href'], $this->viewUrl($view, 8)); + $this->assertEqual($actual_json_page_last['_links']['last']['href'], $this->viewUrl($view, 9)); + $this->assertEqual(array_keys($actual_json_page_last['_links']), array( + 'self', + 'first', + 'prev', + 'last', + )); + } + + /** * Sets up a request on the request stack with a specified format. * * @param string $format @@ -498,7 +792,7 @@ public function testFieldRawOutput() { * Tests the live preview output for json output. */ public function testLivePreview() { - // We set up a request so it looks like an request in the live preview. + // We set up a request so it looks like a request in the live preview. $request = new Request(); $request->setFormat('drupal_ajax', 'application/vnd.drupal-ajax'); $request->headers->set('Accept', 'application/vnd.drupal-ajax'); @@ -508,6 +802,7 @@ public function testLivePreview() { $view = Views::getView('test_serializer_display_entity'); $view->setDisplay('rest_export_1'); + $view->display_handler->setContentType('json'); $this->executeView($view); // Get the serializer service. @@ -531,20 +826,15 @@ public function testLivePreview() { // Change the request format to xml. $view->setDisplay('rest_export_1'); - $view->getDisplay()->setOption('style', array( - 'type' => 'serializer', - 'options' => array( - 'uses_fields' => FALSE, - 'formats' => array( - 'xml' => 'xml', - ), - ), - )); + $view->display_handler->setContentType('xml'); $this->executeView($view); $build = $view->preview(); $rendered_xml = $build['#plain_text']; $this->assertEqual($rendered_xml, $expected, 'Ensure we preview xml when we change the request format.'); + + // @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 2a5818e..2eb605a 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 @@ -53,3 +53,24 @@ 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 + style: + type: serializer + options: + uses_fields: false + formats: + hal_json: hal_json 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 2b981f0..c9e78d0 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 @@ -108,3 +108,31 @@ 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 + row: false + style: + type: serializer + options: + uses_fields: false + formats: + hal_json: hal_json + 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 diff --git a/core/modules/serialization/src/Collection.php b/core/modules/serialization/src/Collection.php new file mode 100644 index 0000000..c6f7301 --- /dev/null +++ b/core/modules/serialization/src/Collection.php @@ -0,0 +1,216 @@ +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(array $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() { + // $this->links should be an array, so no type checking is needed. + return (bool) count($this->links); + } +} diff --git a/core/modules/serialization/tests/src/Unit/CollectionTest.php b/core/modules/serialization/tests/src/Unit/CollectionTest.php new file mode 100644 index 0000000..eadbce8 --- /dev/null +++ b/core/modules/serialization/tests/src/Unit/CollectionTest.php @@ -0,0 +1,110 @@ + '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->randomMachineName(); + $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(), array(), 'By default the items are an empty array'); + + $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'); + } +}