diff --git a/CHANGELOG.md b/CHANGELOG.md index 5540a6a..83fdd5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to this project will be documented in this file, in reverse chronological order by release. +## 0.3.0 - TBD + +### Added + +- [#4](https://github.com/weierophinney/hal/pull/4) adds the ability to force + both links and embedded resources to be rendered as collections, even if the + given relation only contains one item. + + To force a link to be rendered as a collection, pass the attribute + `__FORCE__COLLECTION__` with a boolean value of `true` (or use the constant + `Link::AS_COLLECTION` to refer to the attribute name). + + To force an embedded resource to be rendered as a collection, pass a boolean + `true` as the third argument to `embed()`. Alternately, pass an array + containing the single resource to any of the constructor, `withElement()`, or + `embed()`. + +### Changed + +- Nothing. + +### Deprecated + +- Nothing. + +### Removed + +- Nothing. + +### Fixed + +- Nothing. + ## 0.2.0 - 2017-07-13 ### Added diff --git a/doc/book/representations.md b/doc/book/representations.md index 2f85c30..80021b5 100644 --- a/doc/book/representations.md +++ b/doc/book/representations.md @@ -136,3 +136,82 @@ $response = $factory->createResponse( _Do not_ pass the format (e.g., `+json`, `+xml`) when doing so; the factory will append the appropriate one based on content negotiation. + +## Forcing collections for relations + +HAL allows links and embedded resources to be represented as: + +- a single object +- an array of objects of the same type + +Internally, this package checks to see if only one of the item exists, and, if +so, it will render it by itself. However, there are times you may want to force +an array representation. As an example, if your resource models a car, and you +have a `wheels` relation, it would not make sense to return a single wheel, even +if that's all the car currently has associated with it. + +To accommodate this, we provide two features. + +For links, you may pass a special attribute, `Hal\Link::AS_COLLECTION`, with a +boolean value of `true`; when encountered, this will then be rendered as an +array of links, even if only one link for that relation is present. + +```php +$link = new Link( + 'wheels', + '/api/car/XXXX-YYYY-ZZZZ/wheels/111', + false, + [Link::AS_COLLECTION => true] +); + +$resource = $resource->withLink($link); +``` + +In the above, you will then get the following within your representation: + +```json +"_links": { + "wheels": [ + {"href": "/api/car/XXXX-YYYY-ZZZZ/wheels/111"} + ] +} +``` + +To force an embedded resource to be rendered within an array, you have two +options. + +First, and simplest, pass the resource within an array when calling +`withElement()`, `embed()`, or passing data to the constructor: + +```php +// Constructor: +$resource = new HalResource(['wheels' => [$wheel]]); + +// withElement(): +$resource = $resource->withElement('wheels', [$wheel]); + +// embed(): +$resource = $resource->embed('wheels', [$wheel]); +``` + +Alternately, you can call the `HalResource::embed` method with only the +resource, passing the method a third argument, a flag indicating whether or not +to force an array: + +```php +$resource = $resource->embed('wheels', $wheel, true); +``` + +In each of these cases, assuming no other wheels were provided to the final +resource, you might get a representation such as the following: + +```json +"_embedded": { + "wheels": [ + { + "_links" => {"self": {"href": "..."}} + "id": "..." + }, + ] +} +``` diff --git a/src/HalResource.php b/src/HalResource.php index b93f4ea..785a583 100644 --- a/src/HalResource.php +++ b/src/HalResource.php @@ -184,9 +184,12 @@ public function withElements(array $elements) : HalResource /** * @param string $name * @param Resource|Resource[] $resource + * @param bool $forceCollection Whether or not a single resource or an + * array containing a single resource should be represented as an array of + * resources during representation. * @return Resource */ - public function embed(string $name, $resource) : HalResource + public function embed(string $name, $resource, bool $forceCollection = false) : HalResource { $this->validateElementName($name, __METHOD__); $this->detectCollisionWithData($name, __METHOD__); @@ -200,7 +203,7 @@ public function embed(string $name, $resource) : HalResource )); } $new = clone $this; - $new->embedded[$name] = $this->aggregateEmbeddedResource($name, $resource, __METHOD__); + $new->embedded[$name] = $this->aggregateEmbeddedResource($name, $resource, __METHOD__, $forceCollection); return $new; } @@ -284,10 +287,10 @@ private function detectCollisionWithEmbeddedResource(string $name, string $conte * * @return Resource|Resource[] */ - private function aggregateEmbeddedResource(string $name, $resource, string $context) + private function aggregateEmbeddedResource(string $name, $resource, string $context, bool $forceCollection) { if (! isset($this->embedded[$name])) { - return $resource; + return $forceCollection ? [$resource] : $resource; } // $resource is an collection; existing individual or collection resource exists @@ -373,17 +376,44 @@ private function serializeLinks() $linkRels = $link->getRels(); array_walk($linkRels, function ($rel) use (&$byRelation, $representation) { + $forceCollection = array_key_exists(Link::AS_COLLECTION, $representation) + ? (bool) $representation[Link::AS_COLLECTION] + : false; + unset($representation[Link::AS_COLLECTION]); + if (isset($byRelation[$rel])) { $byRelation[$rel][] = $representation; - return; + } else { + $byRelation[$rel] = [$representation]; + } + + // If we're forcing a collection, and the current relation only + // has one item, mark the relation to force a collection + if (1 === count($byRelation[$rel]) && $forceCollection) { + $byRelation[$rel][Link::AS_COLLECTION] = true; + } + + // If we have more than one link for the relation, and the + // marker for forcing a collection is present, remove the + // marker; it's redundant. Check for a count greater than 2, + // as the marker itself will affect the count! + if (2 < count($byRelation[$rel]) && isset($byRelation[$rel][Link::AS_COLLECTION])) { + unset($byRelation[$rel][Link::AS_COLLECTION]); } - $byRelation[$rel] = [$representation]; }); return $byRelation; }, []); array_walk($relations, function ($links, $key) use (&$relations) { + if (isset($relations[$key][Link::AS_COLLECTION])) { + // If forcing a collection, do nothing to the links, but DO + // remove the marker indicating a collection should be + // returned. + unset($relations[$key][Link::AS_COLLECTION]); + return; + } + $relations[$key] = 1 === count($links) ? array_shift($links) : $links; }); diff --git a/src/Link.php b/src/Link.php index 38fa3e9..946fe49 100644 --- a/src/Link.php +++ b/src/Link.php @@ -7,6 +7,8 @@ class Link implements EvolvableLinkInterface { + const AS_COLLECTION = '__FORCE_COLLECTION__'; + /** * @var array */ diff --git a/test/HalResourceTest.php b/test/HalResourceTest.php index da5f0dd..08876fe 100644 --- a/test/HalResourceTest.php +++ b/test/HalResourceTest.php @@ -498,4 +498,54 @@ public function testJsonSerializeReturnsHalDataStructure(HalResource $resource, { $this->assertEquals($expected, $resource->jsonSerialize()); } + + public function testAllowsForcingResourceToAggregateAsACollection() + { + $resource = (new HalResource()) + ->withLink(new Link('self', '/api/foo')) + ->embed( + 'bar', + new HalResource(['bar' => 'baz'], [new Link('self', '/api/bar')]), + true + ); + + $expected = [ + '_links' => [ + 'self' => [ + 'href' => '/api/foo', + ], + ], + '_embedded' => [ + 'bar' => [ + [ + 'bar' => 'baz', + '_links' => [ + 'self' => ['href' => '/api/bar'], + ], + ], + ], + ], + ]; + + $this->assertEquals($expected, $resource->toArray()); + } + + public function testAllowsForcingLinkToAggregateAsACollection() + { + $link = new Link('foo', '/api/foo', false, [Link::AS_COLLECTION => true]); + $resource = new HalResource(['id' => 'foo'], [$link]); + + $expected = [ + '_links' => [ + 'foo' => [ + [ + 'href' => '/api/foo', + ], + ], + ], + 'id' => 'foo', + ]; + + $this->assertEquals($expected, $resource->toArray()); + } }