Skip to content

Fix route name resolving with subresources #1110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 22, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions features/bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Container;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOffer;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProduct;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question;
Expand Down Expand Up @@ -474,4 +477,23 @@ public function thePasswordForUserShouldBeHashed($password, $user)
throw new \Exception('User password mismatch');
}
}

/**
* @Given I have a product with offers
*/
public function createProductWithOffers()
{
$offer = new DummyOffer();
$offer->setValue(2);
$aggregate = new DummyAggregateOffer();
$aggregate->setValue(1);
$aggregate->addOffer($offer);

$product = new DummyProduct();
$product->setName('Dummy product');
$product->addOffer($aggregate);

$this->manager->persist($product);
$this->manager->flush();
}
}
48 changes: 47 additions & 1 deletion features/main/subresource.feature
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ Feature: Subresource support
}
"""

@dropSchema
Scenario: Get the embedded relation collection
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level"
And the response status code should be 200
Expand All @@ -222,3 +221,50 @@ Feature: Subresource support
}
"""

Scenario: Get offers subresource from aggregate offers subresource
Given I have a product with offers
When I send a "GET" request to "/dummy_products/1/offers/1/offers"
And the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be equal to:
"""
{
"@context": "/contexts/DummyOffer",
"@id": "/dummy_products/1/offers/1/offers",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/dummy_offers/1",
"@type": "DummyOffer",
"id": 1,
"value": 2
}
],
"hydra:totalItems": 1
}
"""

@dropSchema
Scenario: Get offers subresource from aggregate offers subresource
When I send a "GET" request to "/dummy_aggregate_offers/1/offers"
And the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be equal to:
"""
{
"@context": "/contexts/DummyOffer",
"@id": "/dummy_aggregate_offers/1/offers",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/dummy_offers/1",
"@type": "DummyOffer",
"id": 1,
"value": 2
}
],
"hydra:totalItems": 1
}
"""
2 changes: 1 addition & 1 deletion src/Bridge/Symfony/Routing/ApiLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ private function computeSubresourceOperations(RouteCollection $routeCollection,
'_controller' => self::DEFAULT_ACTION_PATTERN.'get_subresource',
'_format' => null,
'_api_resource_class' => $subresource,
'_api_subresource_operation_name' => 'get_subresource_'.$operation['property'],
'_api_subresource_operation_name' => $operation['route_name'],
'_api_subresource_context' => [
'property' => $operation['property'],
'identifiers' => $operation['identifiers'],
Expand Down
11 changes: 9 additions & 2 deletions src/Bridge/Symfony/Routing/CachedRouteNameResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public function __construct(CacheItemPoolInterface $cacheItemPool, RouteNameReso
/**
* {@inheritdoc}
*/
public function getRouteName(string $resourceClass, $operationType): string
public function getRouteName(string $resourceClass, $operationType /**, array $context = []**/): string
{
$cacheKey = self::CACHE_KEY_PREFIX.md5(serialize([$resourceClass, $operationType]));

Expand All @@ -51,7 +51,14 @@ public function getRouteName(string $resourceClass, $operationType): string
// do nothing
}

$routeName = $this->decorated->getRouteName($resourceClass, $operationType);
if (func_num_args() > 2) {
$context = func_get_arg(2);
} else {
$context = [];
@trigger_error(sprintf('Method %s() will have a third `$context = []` argument in version 3.0. Not defining it is deprecated since 2.1.', __METHOD__), E_USER_DEPRECATED);
}

$routeName = $this->decorated->getRouteName($resourceClass, $operationType, $context);

if (!isset($cacheItem)) {
return $routeName;
Expand Down
4 changes: 2 additions & 2 deletions src/Bridge/Symfony/Routing/IriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ public function getItemIriFromResourceClass(string $resourceClass, array $identi
/**
* {@inheritdoc}
*/
public function getSubresourceIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
public function getSubresourceIriFromResourceClass(string $resourceClass, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
try {
return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE), $identifiers, $referenceType);
return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE, $context), $context['subresource_identifiers'], $referenceType);
} catch (RoutingExceptionInterface $e) {
throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e);
}
Expand Down
30 changes: 29 additions & 1 deletion src/Bridge/Symfony/Routing/RouteNameResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Core\Bridge\Symfony\Routing;

use ApiPlatform\Core\Api\OperationType;
use ApiPlatform\Core\Api\OperationTypeDeprecationHelper;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use Symfony\Component\Routing\RouterInterface;
Expand All @@ -34,8 +35,15 @@ public function __construct(RouterInterface $router)
/**
* {@inheritdoc}
*/
public function getRouteName(string $resourceClass, $operationType): string
public function getRouteName(string $resourceClass, $operationType /**, array $context = [] **/): string
{
if (func_num_args() > 2) {
$context = func_get_arg(2);
} else {
$context = [];
@trigger_error(sprintf('Method %s() will have a third `$context = []` argument in version 3.0. Not defining it is deprecated since 2.1.', __METHOD__), E_USER_DEPRECATED);
}

$operationType = OperationTypeDeprecationHelper::getOperationType($operationType);

foreach ($this->router->getRouteCollection()->all() as $routeName => $route) {
Expand All @@ -44,10 +52,30 @@ public function getRouteName(string $resourceClass, $operationType): string
$methods = $route->getMethods();

if ($resourceClass === $currentResourceClass && null !== $operation && (empty($methods) || in_array('GET', $methods, true))) {
if ($operationType === OperationType::SUBRESOURCE && false === $this->isSameSubresource($context, $route->getDefault('_api_subresource_context'))) {
continue;
}

return $routeName;
}
}

throw new InvalidArgumentException(sprintf('No %s route associated with the type "%s".', $operationType, $resourceClass));
}

private function isSameSubresource(array $context, array $currentContext): bool
{
$subresources = array_keys($context['subresource_resources']);
$currentSubresources = [];

foreach ($currentContext['identifiers'] as $identiferContext) {
$currentSubresources[] = $identiferContext[1];
}

if ($currentSubresources === $subresources) {
return true;
}

return false;
}
}
2 changes: 1 addition & 1 deletion src/Bridge/Symfony/Routing/RouteNameResolverInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ interface RouteNameResolverInterface
*
* @return string
*/
public function getRouteName(string $resourceClass, $operationType): string;
public function getRouteName(string $resourceClass, $operationType /**, array $context = [] **/): string;
}
2 changes: 1 addition & 1 deletion src/Hydra/Serializer/CollectionNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public function normalize($object, $format = null, array $context = [])
$context = $this->initContext($resourceClass, $context);

if (isset($context['operation_type']) && $context['operation_type'] === OperationType::SUBRESOURCE) {
$data['@id'] = $this->iriConverter->getSubresourceIriFromResourceClass($resourceClass, $context['subresource_identifiers']);
$data['@id'] = $this->iriConverter->getSubresourceIriFromResourceClass($resourceClass, $context);
} else {
$data['@id'] = $this->iriConverter->getIriFromResourceClass($resourceClass);
}
Expand Down
16 changes: 12 additions & 4 deletions tests/Bridge/Symfony/Routing/CachedRouteNameResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function testConstruct()
/**
* @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException
* @expectedExceptionMessage No item route associated with the type "AppBundle\Entity\User".
* @group legacy
*/
public function testGetRouteNameForItemRouteWithNoMatchingRoute()
{
Expand All @@ -50,14 +51,17 @@ public function testGetRouteNameForItemRouteWithNoMatchingRoute()
$cacheItemPoolProphecy->save($cacheItemProphecy)->shouldNotBeCalled();

$decoratedProphecy = $this->prophesize(RouteNameResolverInterface::class);
$decoratedProphecy->getRouteName('AppBundle\Entity\User', false)
$decoratedProphecy->getRouteName('AppBundle\Entity\User', false, [])
->willThrow(new InvalidArgumentException('No item route associated with the type "AppBundle\Entity\User".'))
->shouldBeCalled();

$cachedRouteNameResolver = new CachedRouteNameResolver($cacheItemPoolProphecy->reveal(), $decoratedProphecy->reveal());
$cachedRouteNameResolver->getRouteName('AppBundle\Entity\User', false);
}

/**
* @group legacy
*/
public function testGetRouteNameForItemRouteOnCacheMiss()
{
$cacheItemProphecy = $this->prophesize(CacheItemInterface::class);
Expand All @@ -69,7 +73,7 @@ public function testGetRouteNameForItemRouteOnCacheMiss()
$cacheItemPoolProphecy->save($cacheItemProphecy)->willReturn(true)->shouldBeCalled();

$decoratedProphecy = $this->prophesize(RouteNameResolverInterface::class);
$decoratedProphecy->getRouteName('AppBundle\Entity\User', false)->willReturn('some_item_route')->shouldBeCalled();
$decoratedProphecy->getRouteName('AppBundle\Entity\User', false, [])->willReturn('some_item_route')->shouldBeCalled();

$cachedRouteNameResolver = new CachedRouteNameResolver($cacheItemPoolProphecy->reveal(), $decoratedProphecy->reveal());
$actual = $cachedRouteNameResolver->getRouteName('AppBundle\Entity\User', false);
Expand Down Expand Up @@ -99,6 +103,7 @@ public function testGetRouteNameForItemRouteOnCacheHit()
/**
* @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException
* @expectedExceptionMessage No collection route associated with the type "AppBundle\Entity\User".
* @group legacy
*/
public function testGetRouteNameForCollectionRouteWithNoMatchingRoute()
{
Expand All @@ -110,14 +115,17 @@ public function testGetRouteNameForCollectionRouteWithNoMatchingRoute()
$cacheItemPoolProphecy->save($cacheItemProphecy)->shouldNotBeCalled();

$decoratedProphecy = $this->prophesize(RouteNameResolverInterface::class);
$decoratedProphecy->getRouteName('AppBundle\Entity\User', true)
$decoratedProphecy->getRouteName('AppBundle\Entity\User', true, [])
->willThrow(new InvalidArgumentException('No collection route associated with the type "AppBundle\Entity\User".'))
->shouldBeCalled();

$cachedRouteNameResolver = new CachedRouteNameResolver($cacheItemPoolProphecy->reveal(), $decoratedProphecy->reveal());
$cachedRouteNameResolver->getRouteName('AppBundle\Entity\User', true);
}

/**
* @group legacy
*/
public function testGetRouteNameForCollectionRouteOnCacheMiss()
{
$cacheItemProphecy = $this->prophesize(CacheItemInterface::class);
Expand All @@ -129,7 +137,7 @@ public function testGetRouteNameForCollectionRouteOnCacheMiss()
$cacheItemPoolProphecy->save($cacheItemProphecy)->willReturn(true)->shouldBeCalled();

$decoratedProphecy = $this->prophesize(RouteNameResolverInterface::class);
$decoratedProphecy->getRouteName('AppBundle\Entity\User', true)->willReturn('some_collection_route')->shouldBeCalled();
$decoratedProphecy->getRouteName('AppBundle\Entity\User', true, [])->willReturn('some_collection_route')->shouldBeCalled();

$cachedRouteNameResolver = new CachedRouteNameResolver($cacheItemPoolProphecy->reveal(), $decoratedProphecy->reveal());
$actual = $cachedRouteNameResolver->getRouteName('AppBundle\Entity\User', true);
Expand Down
10 changes: 6 additions & 4 deletions tests/Bridge/Symfony/Routing/IriConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
use Prophecy\Argument;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Symfony\Component\Routing\RouterInterface;

Expand Down Expand Up @@ -206,7 +208,7 @@ public function testGetSubresourceIriFromResourceClass()
$itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class);

$routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class);
$routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE)->willReturn('api_dummies_related_dummies_get_subresource');
$routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE, Argument::type('array'))->willReturn('api_dummies_related_dummies_get_subresource');

$routerProphecy = $this->prophesize(RouterInterface::class);
$routerProphecy->generate('api_dummies_related_dummies_get_subresource', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1/related_dummies');
Expand All @@ -219,7 +221,7 @@ public function testGetSubresourceIriFromResourceClass()
$routerProphecy->reveal()
);

$this->assertEquals($converter->getSubresourceIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1/related_dummies');
$this->assertEquals($converter->getSubresourceIriFromResourceClass(Dummy::class, ['subresource_identifiers' => ['id' => 1], 'subresource_resources' => [RelatedDummy::class => 1]]), '/dummies/1/related_dummies');
}

/**
Expand All @@ -235,7 +237,7 @@ public function testNotAbleToGenerateGetSubresourceIriFromResourceClass()
$itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class);

$routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class);
$routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE)->willReturn('dummies');
$routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE, Argument::type('array'))->willReturn('dummies');

$routerProphecy = $this->prophesize(RouterInterface::class);
$routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException());
Expand All @@ -248,7 +250,7 @@ public function testNotAbleToGenerateGetSubresourceIriFromResourceClass()
$routerProphecy->reveal()
);

$converter->getSubresourceIriFromResourceClass(Dummy::class, ['id' => 1]);
$converter->getSubresourceIriFromResourceClass(Dummy::class, ['subresource_identifiers' => ['id' => 1], 'subresource_resources' => [RelatedDummy::class => 1]]);
}

public function testGetItemIriFromResourceClass()
Expand Down
12 changes: 9 additions & 3 deletions tests/Bridge/Symfony/Routing/RouteNameResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,15 @@ public function testGetRouteNameForCollectionRoute()
public function testGetRouteNameForSubresourceRoute()
{
$routeCollection = new RouteCollection();
$routeCollection->add('some_subresource_route', new Route('/some/item/path/{id}', [
$routeCollection->add('a_some_subresource_route', new Route('/a/some/item/path/{id}', [
'_api_resource_class' => 'AppBundle\Entity\User',
'_api_subresource_operation_name' => 'some_other_item_op',
'_api_subresource_context' => ['identifiers' => [[1, 'bar']]],
]));
$routeCollection->add('b_some_subresource_route', new Route('/b/some/item/path/{id}', [
'_api_resource_class' => 'AppBundle\Entity\User',
'_api_subresource_operation_name' => 'some_item_op',
'_api_subresource_context' => ['identifiers' => [[1, 'foo']]],
]));
$routeCollection->add('some_collection_route', new Route('/some/collection/path', [
'_api_resource_class' => 'AppBundle\Entity\User',
Expand All @@ -131,8 +137,8 @@ public function testGetRouteNameForSubresourceRoute()
$routerProphecy->getRouteCollection()->willReturn($routeCollection);

$routeNameResolver = new RouteNameResolver($routerProphecy->reveal());
$actual = $routeNameResolver->getRouteName('AppBundle\Entity\User', OperationType::SUBRESOURCE);
$actual = $routeNameResolver->getRouteName('AppBundle\Entity\User', OperationType::SUBRESOURCE, ['subresource_resources' => ['foo' => 1]]);

$this->assertSame('some_subresource_route', $actual);
$this->assertSame('b_some_subresource_route', $actual);
}
}
Loading