Skip to content

Commit 933a650

Browse files
author
abluchet
committed
Enable item route on collection subresources
1 parent 35e48c8 commit 933a650

File tree

7 files changed

+282
-33
lines changed

7 files changed

+282
-33
lines changed

features/main/subresource.feature

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Feature: Subresource support
3030
And the response status code should be 404
3131
And the response should be in JSON
3232

33-
Scenario: Get subresource one to one relation
33+
Scenario: Get recursive subresource one to many relation
3434
When I send a "GET" request to "/questions/1/answer/related_questions"
3535
And the response status code should be 200
3636
And the response should be in JSON
@@ -209,7 +209,7 @@ Feature: Subresource support
209209
}
210210
"""
211211

212-
Scenario: Get filtered embedded relation collection
212+
Scenario: Get filtered embedded relation subresource collection
213213
When I send a "GET" request to "/dummies/1/related_dummies?name=Hello"
214214
And the response status code should be 200
215215
And the response should be in JSON
@@ -272,7 +272,34 @@ Feature: Subresource support
272272
}
273273
"""
274274

275-
Scenario: Get the embedded relation collection at the third level
275+
Scenario: Get the subresource relation item
276+
When I send a "GET" request to "/dummies/1/related_dummies/2"
277+
And the response status code should be 200
278+
And the response should be in JSON
279+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
280+
And the JSON should be equal to:
281+
"""
282+
{
283+
"@context": "/contexts/RelatedDummy",
284+
"@id": "/related_dummies/2",
285+
"@type": "https://schema.org/Product",
286+
"id": 2,
287+
"name": null,
288+
"symfony": "symfony",
289+
"dummyDate": null,
290+
"thirdLevel": {
291+
"@id": "/third_levels/1",
292+
"@type": "ThirdLevel",
293+
"fourthLevel": "/fourth_levels/1"
294+
},
295+
"relatedToDummyFriend": [],
296+
"dummyBoolean": null,
297+
"embeddedDummy": [],
298+
"age": null
299+
}
300+
"""
301+
302+
Scenario: Get the embedded relation subresource item at the third level
276303
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level"
277304
And the response status code should be 200
278305
And the response should be in JSON
@@ -290,7 +317,7 @@ Feature: Subresource support
290317
}
291318
"""
292319

293-
Scenario: Get the embedded relation collection at the fourth level
320+
Scenario: Get the embedded relation subresource item at the fourth level
294321
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level/fourth_level"
295322
And the response status code should be 200
296323
And the response should be in JSON
@@ -355,7 +382,7 @@ Feature: Subresource support
355382
}
356383
"""
357384

358-
Scenario: test
385+
Scenario: Recursive resource
359386
When I send a "GET" request to "/dummy_products/2"
360387
And the response status code should be 200
361388
And the response should be in JSON

src/Bridge/Doctrine/Orm/SubresourceDataProvider.php

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat
152152

153153
$qb = $manager->createQueryBuilder();
154154
$alias = $queryNameGenerator->generateJoinAlias($identifier);
155-
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
156155
$normalizedIdentifiers = [];
157156

158157
if (isset($identifiers[$identifier])) {
@@ -164,25 +163,31 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat
164163
}
165164
}
166165

167-
switch ($relationType) {
168-
// MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
169-
case ClassMetadataInfo::MANY_TO_MANY:
170-
$joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);
171-
172-
$qb->select($joinAlias)
173-
->from($identifierResourceClass, $alias)
174-
->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
175-
break;
176-
case ClassMetadataInfo::ONE_TO_MANY:
177-
$mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
178-
$previousAlias = "$previousAlias.$mappedBy";
179-
180-
$qb->select($alias)
181-
->from($identifierResourceClass, $alias);
182-
break;
183-
default:
184-
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
185-
->from($identifierResourceClass, $alias);
166+
if ($classMetadata->hasAssociation($previousAssociationProperty)) {
167+
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
168+
switch ($relationType) {
169+
// MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
170+
case ClassMetadataInfo::MANY_TO_MANY:
171+
$joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);
172+
173+
$qb->select($joinAlias)
174+
->from($identifierResourceClass, $alias)
175+
->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
176+
break;
177+
case ClassMetadataInfo::ONE_TO_MANY:
178+
$mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
179+
$previousAlias = "$previousAlias.$mappedBy";
180+
181+
$qb->select($alias)
182+
->from($identifierResourceClass, $alias);
183+
break;
184+
default:
185+
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
186+
->from($identifierResourceClass, $alias);
187+
}
188+
} elseif ($classMetadata->isIdentifier($previousAssociationProperty)) {
189+
$qb->select($alias)
190+
->from($identifierResourceClass, $alias);
186191
}
187192

188193
// Add where clause for identifiers

src/Metadata/Property/Factory/AnnotationSubresourceMetadataFactory.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function create(string $resourceClass, string $property, array $options =
5252
$annotation = $this->reader->getPropertyAnnotation($reflectionClass->getProperty($property), ApiSubresource::class);
5353

5454
if (null !== $annotation) {
55-
return $this->updateMetadata($annotation, $propertyMetadata);
55+
return $this->updateMetadata($annotation, $propertyMetadata, $resourceClass);
5656
}
5757
}
5858

@@ -70,19 +70,24 @@ public function create(string $resourceClass, string $property, array $options =
7070
$annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiSubresource::class);
7171

7272
if (null !== $annotation) {
73-
return $this->updateMetadata($annotation, $propertyMetadata);
73+
return $this->updateMetadata($annotation, $propertyMetadata, $resourceClass);
7474
}
7575
}
7676

7777
return $propertyMetadata;
7878
}
7979

80-
private function updateMetadata(ApiSubresource $annotation, PropertyMetadata $propertyMetadata): PropertyMetadata
80+
private function updateMetadata(ApiSubresource $annotation, PropertyMetadata $propertyMetadata, string $originResourceClass): PropertyMetadata
8181
{
8282
$type = $propertyMetadata->getType();
8383
$isCollection = $type->isCollection();
8484
$resourceClass = $isCollection ? $type->getCollectionValueType()->getClassName() : $type->getClassName();
8585
$maxDepth = $annotation->maxDepth;
86+
// @ApiSubresource is on the class identifier (/collection/{id}/subcollection/{subcollectionId})
87+
if (null === $resourceClass) {
88+
$resourceClass = $originResourceClass;
89+
$isCollection = false;
90+
}
8691

8792
return $propertyMetadata->withSubresource(new SubresourceMetadata($resourceClass, $isCollection, $maxDepth));
8893
}

src/Operation/Factory/SubresourceOperationFactory.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
7979
$subresource = $propertyMetadata->getSubresource();
8080
$subresourceClass = $subresource->getResourceClass();
8181
$subresourceMetadata = $this->resourceMetadataFactory->create($subresourceClass);
82+
$isLastItem = $resourceClass === $parentOperation['resource_class'] && $propertyMetadata->isIdentifier();
83+
84+
// A subresource that is also an identifier can't be a start point
85+
if ($isLastItem && (null === $parentOperation || false === $parentOperation['collection'])) {
86+
continue;
87+
}
8288

8389
$visiting = "$resourceClass $property $subresourceClass";
8490

@@ -135,10 +141,12 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
135141
} else {
136142
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
137143
$operation['identifiers'] = $parentOperation['identifiers'];
138-
$operation['identifiers'][] = [$parentOperation['property'], $resourceClass, $parentOperation['collection']];
139-
140-
$operation['operation_name'] = str_replace('get'.self::SUBRESOURCE_SUFFIX, RouteNameGenerator::inflector($property, $operation['collection']).'_get'.self::SUBRESOURCE_SUFFIX, $parentOperation['operation_name']);
141-
144+
$operation['identifiers'][] = [$parentOperation['property'], $resourceClass, $isLastItem ? true : $parentOperation['collection']];
145+
$operation['operation_name'] = str_replace(
146+
'get'.self::SUBRESOURCE_SUFFIX,
147+
RouteNameGenerator::inflector($isLastItem ? 'item' : $property, $operation['collection']).'_get'.self::SUBRESOURCE_SUFFIX,
148+
$parentOperation['operation_name']
149+
);
142150
$operation['route_name'] = str_replace($parentOperation['operation_name'], $operation['operation_name'], $parentOperation['route_name']);
143151

144152
if (!\in_array($resourceMetadata->getShortName(), $operation['shortNames'], true)) {
@@ -151,11 +159,17 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
151159
$operation['path'] = $subresourceOperation['path'];
152160
} else {
153161
$operation['path'] = str_replace(self::FORMAT_SUFFIX, '', $parentOperation['path']);
162+
154163
if ($parentOperation['collection']) {
155164
list($key) = end($operation['identifiers']);
156165
$operation['path'] .= sprintf('/{%s}', $key);
157166
}
158-
$operation['path'] .= sprintf('/%s%s', $this->pathSegmentNameGenerator->getSegmentName($property, $operation['collection']), self::FORMAT_SUFFIX);
167+
168+
if ($isLastItem) {
169+
$operation['path'] .= self::FORMAT_SUFFIX;
170+
} else {
171+
$operation['path'] .= sprintf('/%s%s', $this->pathSegmentNameGenerator->getSegmentName($property, $operation['collection']), self::FORMAT_SUFFIX);
172+
}
159173
}
160174
}
161175

tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ public function testGetSubresource()
138138
$this->assertIdentifierManagerMethodCalls($managerProphecy);
139139

140140
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
141+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
141142
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
142143

143144
$managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
@@ -195,6 +196,7 @@ public function testGetSubSubresourceItem()
195196
$qb->getDQL()->shouldBeCalled()->willReturn($dummyDQL);
196197

197198
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
199+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
198200
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
199201

200202
$dummyManagerProphecy = $this->prophesize(EntityManager::class);
@@ -220,6 +222,7 @@ public function testGetSubSubresourceItem()
220222
$rqb->expr()->shouldBeCalled()->willReturn($relatedExpProphecy->reveal());
221223

222224
$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
225+
$rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true);
223226
$rClassMetadataProphecy->getAssociationMapping('thirdLevel')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_ONE]);
224227

225228
$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
@@ -279,6 +282,7 @@ public function testQueryResultExtension()
279282
$this->assertIdentifierManagerMethodCalls($managerProphecy);
280283

281284
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
285+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
282286
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
283287

284288
$managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
@@ -384,6 +388,7 @@ public function testGetSubSubresourceItemLegacy()
384388
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
385389
$classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
386390
$classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
391+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
387392
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
388393

389394
$dummyManagerProphecy = $this->prophesize(EntityManager::class);
@@ -411,6 +416,7 @@ public function testGetSubSubresourceItemLegacy()
411416
$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
412417
$rClassMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
413418
$rClassMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
419+
$rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true);
414420
$rClassMetadataProphecy->getAssociationMapping('thirdLevel')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_ONE]);
415421

416422
$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
@@ -449,4 +455,92 @@ public function testGetSubSubresourceItemLegacy()
449455

450456
$this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => 1, 'relatedDummies' => 1], $context));
451457
}
458+
459+
public function testGetSubresourceCollectionItem()
460+
{
461+
$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class);
462+
$identifiers = ['id'];
463+
$funcProphecy = $this->prophesize(Func::class);
464+
$func = $funcProphecy->reveal();
465+
466+
// First manager (Dummy)
467+
$dummyDQL = 'dql';
468+
469+
$qb = $this->prophesize(QueryBuilder::class);
470+
$qb->select('relatedDummies_a3')->shouldBeCalled()->willReturn($qb);
471+
$qb->from(Dummy::class, 'id_a2')->shouldBeCalled()->willReturn($qb);
472+
$qb->innerJoin('id_a2.relatedDummies', 'relatedDummies_a3')->shouldBeCalled()->willReturn($qb);
473+
$qb->andWhere('id_a2.id = :id_p2')->shouldBeCalled()->willReturn($qb);
474+
475+
$dummyFunc = new Func('in', ['any']);
476+
477+
$dummyExpProphecy = $this->prophesize(Expr::class);
478+
$dummyExpProphecy->in('relatedDummies_a1', $dummyDQL)->willReturn($dummyFunc)->shouldBeCalled();
479+
480+
$qb->expr()->shouldBeCalled()->willReturn($dummyExpProphecy->reveal());
481+
482+
$qb->getDQL()->shouldBeCalled()->willReturn($dummyDQL);
483+
484+
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
485+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
486+
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
487+
488+
$dummyManagerProphecy = $this->prophesize(EntityManager::class);
489+
$dummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($qb->reveal());
490+
$dummyManagerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
491+
$this->assertIdentifierManagerMethodCalls($dummyManagerProphecy);
492+
493+
$managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($dummyManagerProphecy->reveal());
494+
495+
// Second manager (RelatedDummy)
496+
$relatedDQL = 'relateddql';
497+
498+
$rqb = $this->prophesize(QueryBuilder::class);
499+
$rqb->select('relatedDummies_a1')->shouldBeCalled()->willReturn($rqb);
500+
$rqb->from(RelatedDummy::class, 'relatedDummies_a1')->shouldBeCalled()->willReturn($rqb);
501+
$rqb->andWhere('relatedDummies_a1.id = :id_p1')->shouldBeCalled()->willReturn($rqb);
502+
$rqb->andWhere($dummyFunc)->shouldBeCalled()->willReturn($rqb);
503+
$rqb->getDQL()->shouldBeCalled()->willReturn($relatedDQL);
504+
505+
$relatedExpProphecy = $this->prophesize(Expr::class);
506+
$relatedExpProphecy->in('o', $relatedDQL)->willReturn($func)->shouldBeCalled();
507+
508+
$rqb->expr()->shouldBeCalled()->willReturn($relatedExpProphecy->reveal());
509+
510+
$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
511+
$rClassMetadataProphecy->hasAssociation('id')->shouldBeCalled()->willReturn(false);
512+
$rClassMetadataProphecy->isIdentifier('id')->shouldBeCalled()->willReturn(true);
513+
514+
$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
515+
$rDummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($rqb->reveal());
516+
$rDummyManagerProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($rClassMetadataProphecy->reveal());
517+
$this->assertIdentifierManagerMethodCalls($rDummyManagerProphecy);
518+
519+
$managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($rDummyManagerProphecy->reveal());
520+
521+
$result = new \StdClass();
522+
$queryProphecy = $this->prophesize(AbstractQuery::class);
523+
$queryProphecy->getOneOrNullResult()->shouldBeCalled()->willReturn($result);
524+
525+
$queryBuilder = $this->prophesize(QueryBuilder::class);
526+
527+
$queryBuilder->andWhere($func)->shouldBeCalled()->willReturn($queryBuilder);
528+
529+
$queryBuilder->getQuery()->shouldBeCalled()->willReturn($queryProphecy->reveal());
530+
$queryBuilder->setParameter('id_p1', 2)->shouldBeCalled()->willReturn($queryBuilder);
531+
$queryBuilder->setParameter('id_p2', 1)->shouldBeCalled()->willReturn($queryBuilder);
532+
533+
$repositoryProphecy = $this->prophesize(EntityRepository::class);
534+
$repositoryProphecy->createQueryBuilder('o')->shouldBeCalled()->willReturn($queryBuilder->reveal());
535+
536+
$rDummyManagerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal());
537+
538+
list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]);
539+
540+
$dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory);
541+
542+
$context = ['property' => 'id', 'identifiers' => [['id', Dummy::class, true], ['relatedDummies', RelatedDummy::class, true]], 'collection' => false, ChainIdentifierDenormalizer::HAS_IDENTIFIER_DENORMALIZER => true];
543+
544+
$this->assertEquals($result, $dataProvider->getSubresource(RelatedDummy::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 2]], $context));
545+
}
452546
}

tests/Fixtures/TestBundle/Entity/RelatedDummy.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
class RelatedDummy extends ParentDummy
3232
{
3333
/**
34+
* @ApiSubresource
3435
* @ORM\Column(type="integer")
3536
* @ORM\Id
3637
* @ORM\GeneratedValue(strategy="AUTO")

0 commit comments

Comments
 (0)