diff --git a/docs/book/cookbook/generating-custom-resources.md b/docs/book/cookbook/generating-custom-resources.md index 4a01065..b5c111c 100644 --- a/docs/book/cookbook/generating-custom-resources.md +++ b/docs/book/cookbook/generating-custom-resources.md @@ -22,7 +22,8 @@ interface StrategyInterface $instance, Metadata\AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource; } ``` diff --git a/docs/book/factories.md b/docs/book/factories.md index 67d6f74..c445024 100644 --- a/docs/book/factories.md +++ b/docs/book/factories.md @@ -109,6 +109,7 @@ The additional pairs are as follows: represents the resource identifier; defaults to "id". - `route_params`: an array of additional routing parameters to use when generating the self relational link for the resource. + - `max_depth`: the number of nesting levels processed. Defaults to 10. - For `RouteBasedCollectionMetadata`: - `collection_class`: the collection class the metadata describes. - `collection_relation`: the embedded relation for the collection in the diff --git a/docs/book/resource-generator.md b/docs/book/resource-generator.md index a9e66e2..a3bdfd8 100644 --- a/docs/book/resource-generator.md +++ b/docs/book/resource-generator.md @@ -50,6 +50,7 @@ following information: that maps to the resource identifier) - array `$routeParams = []` (associative array of additional routing parameters to substitute when generating the URI) + - int `$maxDepth = 10` max allowed nesting levels. - `Zend\Expressive\Hal\Metadata\UrlBasedCollectionMetadata`: - string `$class` - string `$collectionRelation` diff --git a/src/Metadata/AbstractMetadata.php b/src/Metadata/AbstractMetadata.php index c0b5a5c..0eb6257 100644 --- a/src/Metadata/AbstractMetadata.php +++ b/src/Metadata/AbstractMetadata.php @@ -9,7 +9,7 @@ use Zend\Expressive\Hal\LinkCollection; -abstract class AbstractMetadata +abstract class AbstractMetadata implements MetadataInterface { use LinkCollection; @@ -22,4 +22,9 @@ public function getClass() : string { return $this->class; } + + public function hasReachedMaxDepth(int $currentDepth): bool + { + return false; + } } diff --git a/src/Metadata/MetadataInterface.php b/src/Metadata/MetadataInterface.php new file mode 100644 index 0000000..321ea10 --- /dev/null +++ b/src/Metadata/MetadataInterface.php @@ -0,0 +1,23 @@ +class = $class; $this->route = $route; @@ -35,6 +39,7 @@ public function __construct( $this->resourceIdentifier = $resourceIdentifier; $this->routeIdentifierPlaceholder = $routeIdentifierPlaceholder; $this->routeParams = $routeParams; + $this->maxDepth = $maxDepth; } public function getRoute() : string @@ -61,4 +66,9 @@ public function setRouteParams(array $routeParams) : void { $this->routeParams = $routeParams; } + + public function hasReachedMaxDepth(int $currentDepth): bool + { + return $currentDepth > $this->maxDepth; + } } diff --git a/src/Metadata/RouteBasedResourceMetadataFactory.php b/src/Metadata/RouteBasedResourceMetadataFactory.php index 328f439..8227e3f 100644 --- a/src/Metadata/RouteBasedResourceMetadataFactory.php +++ b/src/Metadata/RouteBasedResourceMetadataFactory.php @@ -46,6 +46,10 @@ class RouteBasedResourceMetadataFactory implements MetadataFactoryInterface * // generating the self relational link for the collection * // resource. Defaults to an empty array. * 'route_params' => [], + * + * // Max depth to render + * // Defaults to 10. + * 'max_depth' => 10, * ] * * @return AbstractMetadata @@ -72,7 +76,8 @@ public function createMetadata(string $requestedName, array $metadata) : Abstrac $metadata['extractor'], $metadata['resource_identifier'] ?? 'id', $metadata['route_identifier_placeholder'] ?? 'id', - $metadata['route_params'] ?? [] + $metadata['route_params'] ?? [], + $metadata['max_depth'] ?? 10 ); } } diff --git a/src/ResourceGenerator.php b/src/ResourceGenerator.php index d29471b..96c9f35 100644 --- a/src/ResourceGenerator.php +++ b/src/ResourceGenerator.php @@ -123,7 +123,7 @@ public function fromArray(array $data, string $uri = null) : HalResource * against types registered in the metadata map. * @param ServerRequestInterface $request */ - public function fromObject($instance, ServerRequestInterface $request) : HalResource + public function fromObject($instance, ServerRequestInterface $request, int $depth = 0) : HalResource { if (! is_object($instance)) { throw Exception\InvalidObjectException::forNonObject($instance); @@ -146,7 +146,8 @@ public function fromObject($instance, ServerRequestInterface $request) : HalReso $instance, $metadata, $this, - $request + $request, + $depth ); } } diff --git a/src/ResourceGenerator/ExtractCollectionTrait.php b/src/ResourceGenerator/ExtractCollectionTrait.php index 536eba9..5e884b3 100644 --- a/src/ResourceGenerator/ExtractCollectionTrait.php +++ b/src/ResourceGenerator/ExtractCollectionTrait.php @@ -46,21 +46,22 @@ private function extractCollection( Traversable $collection, AbstractCollectionMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { if (! $metadata instanceof AbstractCollectionMetadata) { throw Exception\UnexpectedMetadataTypeException::forCollection($metadata, get_class($this)); } if ($collection instanceof Paginator) { - return $this->extractPaginator($collection, $metadata, $resourceGenerator, $request); + return $this->extractPaginator($collection, $metadata, $resourceGenerator, $request, $depth); } if ($collection instanceof DoctrinePaginator) { - return $this->extractDoctrinePaginator($collection, $metadata, $resourceGenerator, $request); + return $this->extractDoctrinePaginator($collection, $metadata, $resourceGenerator, $request, $depth); } - return $this->extractIterator($collection, $metadata, $resourceGenerator, $request); + return $this->extractIterator($collection, $metadata, $resourceGenerator, $request, $depth); } /** @@ -77,7 +78,8 @@ private function extractPaginator( Paginator $collection, AbstractCollectionMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { $data = ['_total_items' => $collection->getTotalItemCount()]; $pageCount = $collection->count(); @@ -91,7 +93,8 @@ function (int $page) use ($collection) { $collection, $metadata, $resourceGenerator, - $request + $request, + $depth ); } @@ -106,7 +109,8 @@ private function extractDoctrinePaginator( DoctrinePaginator $collection, AbstractCollectionMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { $query = $collection->getQuery(); $totalItems = count($collection); @@ -124,7 +128,8 @@ function (int $page) use ($query, $perPage) { $collection, $metadata, $resourceGenerator, - $request + $request, + $depth ); } @@ -132,14 +137,15 @@ private function extractIterator( Traversable $collection, AbstractCollectionMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { $isCountable = $collection instanceof Countable; $count = $isCountable ? $collection->count() : 0; $resources = []; foreach ($collection as $item) { - $resources[] = $resourceGenerator->fromObject($item, $request); + $resources[] = $resourceGenerator->fromObject($item, $request, $depth + 1); $count = $isCountable ? $count : $count + 1; } @@ -184,7 +190,8 @@ private function createPaginatedCollectionResource( iterable $collection, AbstractCollectionMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { $links = []; $paginationParamType = $metadata->getPaginationParamType(); @@ -197,7 +204,8 @@ private function createPaginatedCollectionResource( $collection, $metadata, $resourceGenerator, - $request + $request, + $depth ); } @@ -236,7 +244,8 @@ private function createPaginatedCollectionResource( $collection, $metadata, $resourceGenerator, - $request + $request, + $depth ); } @@ -255,11 +264,12 @@ private function createCollectionResource( iterable $collection, AbstractCollectionMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { $resources = []; foreach ($collection as $item) { - $resources[] = $resourceGenerator->fromObject($item, $request); + $resources[] = $resourceGenerator->fromObject($item, $request, $depth + 1); } return new HalResource($data, $links, [ diff --git a/src/ResourceGenerator/ExtractInstanceTrait.php b/src/ResourceGenerator/ExtractInstanceTrait.php index f0ad381..c39da7d 100644 --- a/src/ResourceGenerator/ExtractInstanceTrait.php +++ b/src/ResourceGenerator/ExtractInstanceTrait.php @@ -27,7 +27,8 @@ private function extractInstance( $instance, AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : array { $hydrators = $resourceGenerator->getHydrators(); $extractor = $hydrators->get($metadata->getExtractor()); @@ -37,6 +38,10 @@ private function extractInstance( $array = $extractor->extract($instance); + if ($metadata->hasReachedMaxDepth($depth)) { + return $array; + } + // Extract nested resources if present in metadata map $metadataMap = $resourceGenerator->getMetadataMap(); foreach ($array as $key => $value) { @@ -49,7 +54,7 @@ private function extractInstance( continue; } - $childData = $resourceGenerator->fromObject($value, $request); + $childData = $resourceGenerator->fromObject($value, $request, $depth + 1); // Nested collections need to be merged. $childMetadata = $metadataMap->get($childClass); diff --git a/src/ResourceGenerator/RouteBasedCollectionStrategy.php b/src/ResourceGenerator/RouteBasedCollectionStrategy.php index c1e3ee3..b208001 100644 --- a/src/ResourceGenerator/RouteBasedCollectionStrategy.php +++ b/src/ResourceGenerator/RouteBasedCollectionStrategy.php @@ -25,7 +25,8 @@ public function createResource( $instance, Metadata\AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { if (! $metadata instanceof Metadata\RouteBasedCollectionMetadata) { throw Exception\UnexpectedMetadataTypeException::forMetadata( diff --git a/src/ResourceGenerator/RouteBasedResourceStrategy.php b/src/ResourceGenerator/RouteBasedResourceStrategy.php index e040851..47685c5 100644 --- a/src/ResourceGenerator/RouteBasedResourceStrategy.php +++ b/src/ResourceGenerator/RouteBasedResourceStrategy.php @@ -20,7 +20,8 @@ public function createResource( $instance, Metadata\AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { if (! $metadata instanceof Metadata\RouteBasedResourceMetadata) { throw Exception\UnexpectedMetadataTypeException::forMetadata( @@ -34,7 +35,8 @@ public function createResource( $instance, $metadata, $resourceGenerator, - $request + $request, + $depth ); $routeParams = $metadata->getRouteParams(); @@ -45,6 +47,10 @@ public function createResource( $routeParams[$routeIdentifier] = $data[$resourceIdentifier]; } + if ($metadata->hasReachedMaxDepth($depth)) { + $data = []; + } + return new HalResource($data, [ $resourceGenerator->getLinkGenerator()->fromRoute( 'self', diff --git a/src/ResourceGenerator/StrategyInterface.php b/src/ResourceGenerator/StrategyInterface.php index 03cb97b..370405c 100644 --- a/src/ResourceGenerator/StrategyInterface.php +++ b/src/ResourceGenerator/StrategyInterface.php @@ -23,6 +23,7 @@ public function createResource( $instance, Metadata\AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource; } diff --git a/src/ResourceGenerator/UrlBasedCollectionStrategy.php b/src/ResourceGenerator/UrlBasedCollectionStrategy.php index ddfb16f..0c2a035 100644 --- a/src/ResourceGenerator/UrlBasedCollectionStrategy.php +++ b/src/ResourceGenerator/UrlBasedCollectionStrategy.php @@ -33,7 +33,8 @@ public function createResource( $instance, Metadata\AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { if (! $metadata instanceof Metadata\UrlBasedCollectionMetadata) { throw Exception\UnexpectedMetadataTypeException::forMetadata( diff --git a/src/ResourceGenerator/UrlBasedResourceStrategy.php b/src/ResourceGenerator/UrlBasedResourceStrategy.php index 2008e8a..c74cb98 100644 --- a/src/ResourceGenerator/UrlBasedResourceStrategy.php +++ b/src/ResourceGenerator/UrlBasedResourceStrategy.php @@ -21,7 +21,8 @@ public function createResource( $instance, Metadata\AbstractMetadata $metadata, ResourceGenerator $resourceGenerator, - ServerRequestInterface $request + ServerRequestInterface $request, + int $depth = 0 ) : HalResource { if (! $metadata instanceof Metadata\UrlBasedResourceMetadata) { throw Exception\UnexpectedMetadataTypeException::forMetadata( diff --git a/test/ResourceGenerator/DoctrinePaginatorTest.php b/test/ResourceGenerator/DoctrinePaginatorTest.php index b93e63f..8b7bb9a 100644 --- a/test/ResourceGenerator/DoctrinePaginatorTest.php +++ b/test/ResourceGenerator/DoctrinePaginatorTest.php @@ -123,7 +123,8 @@ public function testDoesNotCreateLinksForUnknownPaginationParamType() $this->generator ->fromObject( (object) ['value' => $value], - Argument::that([$this->request, 'reveal']) + Argument::that([$this->request, 'reveal']), + 1 ) ->will(function () use ($testCase) { $resource = $testCase->prophesize(HalResource::class); @@ -182,7 +183,8 @@ public function testCreatesLinksForQueryBasedPagination() $this->generator ->fromObject( (object) ['value' => $value], - Argument::that([$this->request, 'reveal']) + Argument::that([$this->request, 'reveal']), + 1 ) ->will(function () use ($testCase) { $resource = $testCase->prophesize(HalResource::class); @@ -250,7 +252,8 @@ public function testCreatesLinksForRouteBasedPagination() $this->generator ->fromObject( (object) ['value' => $value], - Argument::that([$this->request, 'reveal']) + Argument::that([$this->request, 'reveal']), + 1 ) ->will(function () use ($testCase) { $resource = $testCase->prophesize(HalResource::class); diff --git a/test/ResourceGenerator/ResourceWithNestedInstancesTest.php b/test/ResourceGenerator/ResourceWithNestedInstancesTest.php index 0b6f756..dd1dd8d 100644 --- a/test/ResourceGenerator/ResourceWithNestedInstancesTest.php +++ b/test/ResourceGenerator/ResourceWithNestedInstancesTest.php @@ -73,7 +73,11 @@ public function createMetadataMap() $fooBarMetadata = new RouteBasedResourceMetadata( TestAsset\FooBar::class, 'foo-bar', - self::getObjectPropertyHydratorClass() + self::getObjectPropertyHydratorClass(), + 'id', + 'id', + [], + 10 ); $metadataMap->has(TestAsset\FooBar::class)->willReturn(true); @@ -82,7 +86,11 @@ public function createMetadataMap() $childMetadata = new RouteBasedResourceMetadata( TestAsset\Child::class, 'child', - self::getObjectPropertyHydratorClass() + self::getObjectPropertyHydratorClass(), + 'id', + 'id', + [], + 10 ); $metadataMap->has(TestAsset\Child::class)->willReturn(true); diff --git a/test/ResourceGenerator/ResourceWithSelfReferringInstanceTest.php b/test/ResourceGenerator/ResourceWithSelfReferringInstanceTest.php new file mode 100644 index 0000000..42be3cb --- /dev/null +++ b/test/ResourceGenerator/ResourceWithSelfReferringInstanceTest.php @@ -0,0 +1,108 @@ +id = 1234; + $parent->foo = 'FOO'; + $parent->bar = $parent; + + $request = $this->prophesize(ServerRequestInterface::class); + + $metadataMap = $this->createMetadataMap(); + $hydrators = $this->createHydrators(); + $linkGenerator = $this->createLinkGenerator($request); + + $generator = new ResourceGenerator( + $metadataMap->reveal(), + $hydrators->reveal(), + $linkGenerator->reveal() + ); + + $generator->addStrategy( + RouteBasedResourceMetadata::class, + ResourceGenerator\RouteBasedResourceStrategy::class + ); + + $generator->addStrategy( + RouteBasedCollectionMetadata::class, + ResourceGenerator\RouteBasedCollectionStrategy::class + ); + + $resource = $generator->fromObject($parent, $request->reveal()); + $this->assertInstanceOf(HalResource::class, $resource); + + $childResource = $resource->getElement('bar'); + $this->assertInstanceOf(HalResource::class, $childResource); + $this->assertCount(0, $childResource->getElements()); + } + + public function createMetadataMap() + { + $metadataMap = $this->prophesize(MetadataMap::class); + + $fooBarMetadata = new RouteBasedResourceMetadata( + TestAsset\FooBar::class, + 'foo-bar', + self::getObjectPropertyHydratorClass(), + 'id', + 'id', + [], + 0 + ); + + $metadataMap->has(TestAsset\FooBar::class)->willReturn(true); + $metadataMap->get(TestAsset\FooBar::class)->willReturn($fooBarMetadata); + + return $metadataMap; + } + + public function createLinkGenerator($request) + { + $linkGenerator = $this->prophesize(LinkGenerator::class); + + $linkGenerator + ->fromRoute( + 'self', + $request->reveal(), + 'foo-bar', + [ 'id' => 1234 ] + ) + ->willReturn(new Link('self', '/api/foo-bar/1234')); + + return $linkGenerator; + } + + public function createHydrators() + { + $hydratorClass = self::getObjectPropertyHydratorClass(); + + $hydrators = $this->prophesize(ContainerInterface::class); + $hydrators->get($hydratorClass)->willReturn(new $hydratorClass()); + return $hydrators; + } +}