diff --git a/docs/ref/advanced-rendering.rst b/docs/ref/advanced-rendering.rst index 00473f84..1a7054a0 100644 --- a/docs/ref/advanced-rendering.rst +++ b/docs/ref/advanced-rendering.rst @@ -25,7 +25,8 @@ However, it does expose a few pieces of functionality that may be of interest: URLs (i.e., contains schema, hostname, and port, in addition to path). This was detailed :ref:`in the previous section `. - The ``getIdFromResource`` event (also detailed :ref:`in the previous section - `). + `). +- The ``renderResource`` and ``renderCollection`` events. - The ``renderCollection.resource`` event. If in a controller, or interacting with a controller instance, you can access it @@ -36,8 +37,53 @@ via the controller's ``plugin()`` method: $halLinks = $controller->plugin('HalLinks'); For the purposes of this chapter, we'll look specifically at the -``renderCollection.resource`` event, as it allows you, the developer, to fully -customize how you extract your resource to an array. +``renderResource``, ``renderCollection``, and ``renderCollection.resource`` +events, as they allow you, the developer, to fully customize how you extract +your resource to an array as well as manipulate links. + +The renderResource and renderCollection events +---------------------------------------------- + +These events are triggered each time you render a resource or collection, even +if they are embedded. As such, this allows you to introspect these items and +manipulate them prior to extracting them to a representation. + +As an example, let's say we want to inject a "describedby" link into any +``My\User`` resource and/or ``My\Users`` collection. We can do this as follows: + +.. code-block:: php + :linenos: + + $sharedEvents->attach( + 'PhlyRestfully\Plugin\HalLinks', + array('renderResource', 'renderCollection'), + function ($e) { + $resource = $e->getParam('resource', false); + $collection = $e->getParam('collection', false); + if (!$resource && !$collection) { + return; + } + if ($resource && !$resource instanceof \My\User) { + return; + } + if ($collection && !$collection instanceof \My\Users) { + return; + } + if ($collection) { + $resource = $collection; + } + $links = $resource->getLinks(); + $links->add(\PhlyRestfully\Link::factory(array( + 'rel' => 'describedby', + 'url' => 'http://example.com/api/help/resources/user', + ))); + } + ); + +The above attaches to both events, and then checks if we have a resource or +collection we're interested in; if so, it creates a link and injects it into the +composed link collection. This will ensure we have a "describedby" relational +link in each one of these rendered resources. The renderCollection.resource event ----------------------------------- diff --git a/docs/ref/metadata-map.rst b/docs/ref/metadata-map.rst index 59741c55..74b30d30 100644 --- a/docs/ref/metadata-map.rst +++ b/docs/ref/metadata-map.rst @@ -65,6 +65,11 @@ The following options are available for metadata maps: defaults to "id". (**OPTIONAL**) - **is_collection**: boolean flag indicating whether or not the resource is a collection; defaults to "false". (**OPTIONAL**) +- **links**: array of additional relational links to use with the resource or + collection. Each item in the array is itself an array, with the required key + "rel" (describing the relation), and one of either "url" (a string) or "route" + (an array with the members: "name", required; "params", an array, optional; + and "options", an array, optional). (**OPTIONAL**) - **resource_route**: the name of the route to use for resources embedded as part of a collection. If not set, the route for the resource is used. (**OPTIONAL**) - **route**: the name of the route to use for generating the "self" relational diff --git a/src/PhlyRestfully/Link.php b/src/PhlyRestfully/Link.php index 1a00d424..7f3fc6c8 100644 --- a/src/PhlyRestfully/Link.php +++ b/src/PhlyRestfully/Link.php @@ -54,6 +54,59 @@ public function __construct($relation) $this->relation = (string) $relation; } + /** + * Factory for creating links + * + * @param array $spec + * @return self + * @throws Exception\InvalidArgumentException if missing a "rel" or invalid route specifications + */ + public static function factory(array $spec) + { + if (!isset($spec['rel'])) { + throw new Exception\InvalidArgumentException(sprintf( + '%s requires that the specification array contain a "rel" element; none found', + __METHOD__ + )); + } + $link = new static($spec['rel']); + + if (isset($spec['url'])) { + $link->setUrl($spec['url']); + return $link; + } + + if (isset($spec['route'])) { + $routeInfo = $spec['route']; + if (is_string($routeInfo)) { + $link->setRoute($routeInfo); + return $link; + } + + if (!is_array($routeInfo)) { + throw new Exception\InvalidArgumentException(sprintf( + '%s requires that the specification array\'s "route" element be a string or array; received "%s"', + __METHOD__, + (is_object($routeInfo) ? get_class($routeInfo) : gettype($routeInfo)) + )); + } + + if (!isset($routeInfo['name'])) { + throw new Exception\InvalidArgumentException(sprintf( + '%s requires that the specification array\'s "route" array contain a "name" element; none found', + __METHOD__ + )); + } + $name = $routeInfo['name']; + $params = isset($routeInfo['params']) && is_array($routeInfo['params']) ? $routeInfo['params'] : array(); + $options = isset($routeInfo['options']) && is_array($routeInfo['options']) ? $routeInfo['options'] : array(); + $link->setRoute($name, $params, $options); + return $link; + } + + return $link; + } + /** * Set the route to use when generating the relation URI * diff --git a/src/PhlyRestfully/Metadata.php b/src/PhlyRestfully/Metadata.php index 64905571..ef77f8e5 100644 --- a/src/PhlyRestfully/Metadata.php +++ b/src/PhlyRestfully/Metadata.php @@ -46,6 +46,13 @@ class Metadata */ protected $isCollection = false; + /** + * Collection of additional relational links to inject in resource + * + * @var array + */ + protected $links = array(); + /** * Route for resources composed in a collection * @@ -154,6 +161,16 @@ public function getIdentifierName() return $this->identifierName; } + /** + * Retrieve set of relational links to inject, if any + * + * @return array + */ + public function getLinks() + { + return $this->links; + } + /** * Retrieve the resource route * @@ -312,6 +329,27 @@ public function setIsCollection($flag) return $this; } + /** + * Set relational links. + * + * Each element in the array should be an array with the elements: + * + * - rel - the link relation + * - url - the URL to use for the link OR + * - route - an array of route information for generating the link; this + * should include the elements "name" (required; the route name), + * "params" (optional; additional parameters to inject), and "options" + * (optional; additional options to pass to the router for assembly) + * + * @param array $links + * @return self + */ + public function setLinks(array $links) + { + $this->links = $links; + return $this; + } + /** * Set the resource route (for embedded resources in collections) * diff --git a/src/PhlyRestfully/Plugin/HalLinks.php b/src/PhlyRestfully/Plugin/HalLinks.php index 39665dab..9bbc003d 100644 --- a/src/PhlyRestfully/Plugin/HalLinks.php +++ b/src/PhlyRestfully/Plugin/HalLinks.php @@ -317,6 +317,7 @@ public function getHydratorForResource($resource) */ public function renderCollection(HalCollection $halCollection) { + $this->getEventManager()->trigger(__FUNCTION__, $this, array('collection' => $halCollection)); $collection = $halCollection->collection; $collectionName = $halCollection->collectionName; @@ -349,6 +350,7 @@ public function renderCollection(HalCollection $halCollection) */ public function renderResource(HalResource $halResource) { + $this->getEventManager()->trigger(__FUNCTION__, $this, array('resource' => $halResource)); $resource = $halResource->resource; $id = $halResource->id; $links = $this->fromResource($halResource); @@ -543,20 +545,13 @@ public function createResourceFromMetadata($object, Metadata $metadata) $id = $data[$identiferName]; $resource = new HalResource($data, $id); - - $link = new Link('self'); - if ($metadata->hasRoute()) { - $params = array_merge($metadata->getRouteParams(), array($identiferName => $id)); - $link->setRoute($metadata->getRoute(), $params, $metadata->getRouteOptions()); - } elseif ($metadata->hasUrl()) { - $link->setUrl($metadata->getUrl()); - } else { - throw new Exception\RuntimeException(sprintf( - 'Unable to create a self link for resource of type "%s"; metadata does not contain a route or a url', - get_class($object) - )); + $links = $resource->getLinks(); + $this->marshalMetadataLinks($metadata, $links); + if (!$links->has('self')) { + $link = $this->marshalSelfLinkFromMetadata($metadata, $object, $id, $identiferName); + $links->add($link); } - $resource->getLinks()->add($link); + return $resource; } @@ -624,6 +619,7 @@ public function createCollectionFromMetadata($object, Metadata $metadata) $collection->setCollectionRoute($metadata->getRoute()); $collection->setResourceRoute($metadata->getResourceRoute()); $collection->setIdentifierName($metadata->getIdentifierName()); + $this->marshalMetadataLinks($metadata, $collection->getLinks()); return $collection; } @@ -893,4 +889,47 @@ protected function convertResourceToArray($resource) return $hydrator->extract($resource); } + /** + * Creates a link object, given metadata and a resource + * + * @param Metadata $metadata + * @param object $object + * @param string $id + * @param string $identifierName + * @return Link + * @throws Exception\RuntimeException + */ + protected function marshalSelfLinkFromMetadata(Metadata $metadata, $object, $id, $identifierName) + { + $link = new Link('self'); + if ($metadata->hasUrl()) { + $link->setUrl($metadata->getUrl()); + return $link; + } + + if (!$metadata->hasRoute()) { + throw new Exception\RuntimeException(sprintf( + 'Unable to create a self link for resource of type "%s"; metadata does not contain a route or a url', + get_class($object) + )); + } + + $params = array_merge($metadata->getRouteParams(), array($identifierName => $id)); + $link->setRoute($metadata->getRoute(), $params, $metadata->getRouteOptions()); + return $link; + } + + /** + * Inject any links found in the metadata into the resource's link collection + * + * @param Metadata $metadata + * @param LinkCollection $links + */ + protected function marshalMetadataLinks(Metadata $metadata, LinkCollection $links) + { + foreach ($metadata->getLinks() as $linkData) { + $link = Link::factory($linkData); + $links->add($link); + } + } } diff --git a/test/PhlyRestfullyTest/LinkTest.php b/test/PhlyRestfullyTest/LinkTest.php index 471868d5..d91f3fef 100644 --- a/test/PhlyRestfullyTest/LinkTest.php +++ b/test/PhlyRestfullyTest/LinkTest.php @@ -13,7 +13,7 @@ class LinkTest extends TestCase { - public function testConstructorTakesLinkReationName() + public function testConstructorTakesLinkRelationName() { $link = new Link('describedby'); $this->assertEquals('describedby', $link->getRelation()); @@ -140,4 +140,45 @@ public function testIsCompleteReturnsTrueWhenRouteIsSet() $link->setRoute('api/docs'); $this->assertTrue($link->isComplete()); } + + /** + * @group 79 + */ + public function testFactoryCanGenerateLinkWithUrl() + { + $rel = 'describedby'; + $url = 'http://example.com/docs.html'; + $link = Link::factory(array( + 'rel' => $rel, + 'url' => $url, + )); + $this->assertInstanceOf('PhlyRestfully\Link', $link); + $this->assertEquals($rel, $link->getRelation()); + $this->assertEquals($url, $link->getUrl()); + } + + /** + * @group 79 + */ + public function testFactoryCanGenerateLinkWithRouteInformation() + { + $rel = 'describedby'; + $route = 'api/docs'; + $params = array('version' => '1.1'); + $options = array('query' => 'version=1.1'); + $link = Link::factory(array( + 'rel' => $rel, + 'route' => array( + 'name' => $route, + 'params' => $params, + 'options' => $options, + ), + )); + + $this->assertInstanceOf('PhlyRestfully\Link', $link); + $this->assertEquals('describedby', $link->getRelation()); + $this->assertEquals($route, $link->getRoute()); + $this->assertEquals($params, $link->getRouteParams()); + $this->assertEquals($options, $link->getRouteOptions()); + } } diff --git a/test/PhlyRestfullyTest/Plugin/HalLinksTest.php b/test/PhlyRestfullyTest/Plugin/HalLinksTest.php index 286934f4..b5713f2b 100644 --- a/test/PhlyRestfullyTest/Plugin/HalLinksTest.php +++ b/test/PhlyRestfullyTest/Plugin/HalLinksTest.php @@ -47,7 +47,16 @@ public function setUp() 'type' => 'segment', 'options' => array( 'route' => '/resource[/:id]' - ) + ), + 'may_terminate' => true, + 'child_routes' => array( + 'children' => array( + 'type' => 'literal', + 'options' => array( + 'route' => '/children', + ), + ), + ), ), 'users' => array( 'type' => 'segment', @@ -119,7 +128,6 @@ public function testCreateLinkSkipServerUrlHelperIfSchemeExists() $this->assertEquals('http://localhost.localdomain/resource', $url); } - public function testLinkCreationWithoutIdCreatesFullyQualifiedLink() { $url = $this->plugin->createLink('resource'); @@ -458,4 +466,141 @@ public function testResourcesFromCollectionCanUseHydratorSetInMetadataMap() $this->assertArrayHasKey('id', $testResource); $this->assertArrayHasKey('name', $testResource); } + + /** + * @group 79 + */ + public function testInjectsLinksFromMetadataWhenCreatingResource() + { + $object = new TestAsset\Resource('foo', 'Foo'); + $resource = new HalResource($object, 'foo'); + + $metadata = new MetadataMap(array( + 'PhlyRestfullyTest\Plugin\TestAsset\Resource' => array( + 'hydrator' => 'Zend\Stdlib\Hydrator\ObjectProperty', + 'route' => 'hostname/resource', + 'links' => array( + array( + 'rel' => 'describedby', + 'url' => 'http://example.com/api/help/resource', + ), + array( + 'rel' => 'children', + 'route' => array( + 'name' => 'resource/children', + ), + ), + ), + ), + )); + + $this->plugin->setMetadataMap($metadata); + $resource = $this->plugin->createResourceFromMetadata($object, $metadata->get('PhlyRestfullyTest\Plugin\TestAsset\Resource')); + $this->assertInstanceof('PhlyRestfully\HalResource', $resource); + $links = $resource->getLinks(); + $this->assertTrue($links->has('describedby')); + $this->assertTrue($links->has('children')); + + $describedby = $links->get('describedby'); + $this->assertTrue($describedby->hasUrl()); + $this->assertEquals('http://example.com/api/help/resource', $describedby->getUrl()); + + $children = $links->get('children'); + $this->assertTrue($children->hasRoute()); + $this->assertEquals('resource/children', $children->getRoute()); + } + + /** + * @group 79 + */ + public function testInjectsLinksFromMetadataWhenCreatingCollection() + { + $set = new TestAsset\Collection( + array( + (object) array('id' => 'foo', 'name' => 'foo'), + (object) array('id' => 'bar', 'name' => 'bar'), + (object) array('id' => 'baz', 'name' => 'baz'), + ) + ); + + $metadata = new MetadataMap(array( + 'PhlyRestfullyTest\Plugin\TestAsset\Collection' => array( + 'is_collection' => true, + 'route' => 'hostname/contacts', + 'resource_route' => 'hostname/embedded', + 'links' => array( + array( + 'rel' => 'describedby', + 'url' => 'http://example.com/api/help/collection', + ), + ), + ), + )); + + $this->plugin->setMetadataMap($metadata); + + $collection = $this->plugin->createCollectionFromMetadata( + $set, + $metadata->get('PhlyRestfullyTest\Plugin\TestAsset\Collection' + )); + $this->assertInstanceof('PhlyRestfully\HalCollection', $collection); + $links = $collection->getLinks(); + $this->assertTrue($links->has('describedby')); + $link = $links->get('describedby'); + $this->assertTrue($link->hasUrl()); + $this->assertEquals('http://example.com/api/help/collection', $link->getUrl()); + } + + /** + * @group 79 + */ + public function testRenderResourceTriggersEvent() + { + $resource = new HalResource( + (object) array( + 'id' => 'user', + 'name' => 'matthew', + ), + 'user' + ); + $self = new Link('self'); + $self->setRoute('hostname/users', array('id' => 'user')); + $resource->getLinks()->add($self); + + $this->plugin->getEventManager()->attach('renderResource', function ($e) { + $resource = $e->getParam('resource'); + $resource->getLinks()->get('self')->setRouteParams(array('id' => 'matthew')); + }); + + $rendered = $this->plugin->renderResource($resource); + $this->assertContains('/users/matthew', $rendered['_links']['self']['href']); + } + + /** + * @group 79 + */ + public function testRenderCollectionTriggersEvent() + { + $collection = new HalCollection( + array( + (object) array('id' => 'foo', 'name' => 'foo'), + (object) array('id' => 'bar', 'name' => 'bar'), + (object) array('id' => 'baz', 'name' => 'baz'), + ), + 'hostname/contacts' + ); + $self = new Link('self'); + $self->setRoute('hostname/contacts'); + $collection->getLinks()->add($self); + $collection->setCollectionName('resources'); + + $this->plugin->getEventManager()->attach('renderCollection', function ($e) { + $collection = $e->getParam('collection'); + $collection->setAttributes(array('injected' => true)); + }); + + $rendered = $this->plugin->renderCollection($collection); + $this->assertArrayHasKey('injected', $rendered); + $this->assertTrue($rendered['injected']); + } }