Skip to content

Commit a675a8e

Browse files
abluchetteohhanhui
authored andcommitted
Enable item route on collection subresources
1 parent a4bfa4e commit a675a8e

File tree

7 files changed

+284
-33
lines changed

7 files changed

+284
-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
@@ -356,7 +383,7 @@ Feature: Subresource support
356383
"""
357384

358385
@dropSchema
359-
Scenario: test
386+
Scenario: Recursive resource
360387
When I send a "GET" request to "/dummy_products/2"
361388
And the response status code should be 200
362389
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
@@ -151,32 +151,37 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat
151151

152152
$qb = $manager->createQueryBuilder();
153153
$alias = $queryNameGenerator->generateJoinAlias($identifier);
154-
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
155154
$normalizedIdentifiers = isset($identifiers[$identifier]) ? $this->normalizeIdentifiers(
156155
$identifiers[$identifier],
157156
$manager,
158157
$identifierResourceClass
159158
) : [];
160159

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

182187
// 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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ public function testGetSubresource()
143143
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
144144
$classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
145145
$classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
146+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
146147
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
147148

148149
$managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
@@ -202,6 +203,7 @@ public function testGetSubSubresourceItem()
202203
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
203204
$classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
204205
$classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
206+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
205207
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
206208

207209
$dummyManagerProphecy = $this->prophesize(EntityManager::class);
@@ -229,6 +231,7 @@ public function testGetSubSubresourceItem()
229231
$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
230232
$rClassMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
231233
$rClassMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
234+
$rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true);
232235
$rClassMetadataProphecy->getAssociationMapping('thirdLevel')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_ONE]);
233236

234237
$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
@@ -290,6 +293,7 @@ public function testQueryResultExtension()
290293
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
291294
$classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
292295
$classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
296+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
293297
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
294298

295299
$managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
@@ -363,4 +367,96 @@ public function testThrowResourceClassNotSupportedException()
363367
$dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory);
364368
$dataProvider->getSubresource(Dummy::class, ['id' => 1], []);
365369
}
370+
371+
public function testGetSubresourceCollectionItem()
372+
{
373+
$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class);
374+
$identifiers = ['id'];
375+
$funcProphecy = $this->prophesize(Func::class);
376+
$func = $funcProphecy->reveal();
377+
378+
// First manager (Dummy)
379+
$dummyDQL = 'dql';
380+
381+
$qb = $this->prophesize(QueryBuilder::class);
382+
$qb->select('relatedDummies_a3')->shouldBeCalled()->willReturn($qb);
383+
$qb->from(Dummy::class, 'id_a2')->shouldBeCalled()->willReturn($qb);
384+
$qb->innerJoin('id_a2.relatedDummies', 'relatedDummies_a3')->shouldBeCalled()->willReturn($qb);
385+
$qb->andWhere('id_a2.id = :id_p2')->shouldBeCalled()->willReturn($qb);
386+
387+
$dummyFunc = new Func('in', ['any']);
388+
389+
$dummyExpProphecy = $this->prophesize(Expr::class);
390+
$dummyExpProphecy->in('relatedDummies_a1', $dummyDQL)->willReturn($dummyFunc)->shouldBeCalled();
391+
392+
$qb->expr()->shouldBeCalled()->willReturn($dummyExpProphecy->reveal());
393+
394+
$qb->getDQL()->shouldBeCalled()->willReturn($dummyDQL);
395+
396+
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
397+
$classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
398+
$classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
399+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
400+
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);
401+
402+
$dummyManagerProphecy = $this->prophesize(EntityManager::class);
403+
$dummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($qb->reveal());
404+
$dummyManagerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
405+
$this->assertIdentifierManagerMethodCalls($dummyManagerProphecy);
406+
407+
$managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($dummyManagerProphecy->reveal());
408+
409+
// Second manager (RelatedDummy)
410+
$relatedDQL = 'relateddql';
411+
412+
$rqb = $this->prophesize(QueryBuilder::class);
413+
$rqb->select('relatedDummies_a1')->shouldBeCalled()->willReturn($rqb);
414+
$rqb->from(RelatedDummy::class, 'relatedDummies_a1')->shouldBeCalled()->willReturn($rqb);
415+
$rqb->andWhere('relatedDummies_a1.id = :id_p1')->shouldBeCalled()->willReturn($rqb);
416+
$rqb->andWhere($dummyFunc)->shouldBeCalled()->willReturn($rqb);
417+
$rqb->getDQL()->shouldBeCalled()->willReturn($relatedDQL);
418+
419+
$relatedExpProphecy = $this->prophesize(Expr::class);
420+
$relatedExpProphecy->in('o', $relatedDQL)->willReturn($func)->shouldBeCalled();
421+
422+
$rqb->expr()->shouldBeCalled()->willReturn($relatedExpProphecy->reveal());
423+
424+
$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
425+
$rClassMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
426+
$rClassMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
427+
$rClassMetadataProphecy->hasAssociation('id')->shouldBeCalled()->willReturn(false);
428+
$rClassMetadataProphecy->isIdentifier('id')->shouldBeCalled()->willReturn(true);
429+
430+
$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
431+
$rDummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($rqb->reveal());
432+
$rDummyManagerProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($rClassMetadataProphecy->reveal());
433+
$this->assertIdentifierManagerMethodCalls($rDummyManagerProphecy);
434+
435+
$managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($rDummyManagerProphecy->reveal());
436+
437+
$result = new \StdClass();
438+
$queryProphecy = $this->prophesize(AbstractQuery::class);
439+
$queryProphecy->getOneOrNullResult()->shouldBeCalled()->willReturn($result);
440+
441+
$queryBuilder = $this->prophesize(QueryBuilder::class);
442+
443+
$queryBuilder->andWhere($func)->shouldBeCalled()->willReturn($queryBuilder);
444+
445+
$queryBuilder->getQuery()->shouldBeCalled()->willReturn($queryProphecy->reveal());
446+
$queryBuilder->setParameter('id_p1', 2)->shouldBeCalled()->willReturn($queryBuilder);
447+
$queryBuilder->setParameter('id_p2', 1)->shouldBeCalled()->willReturn($queryBuilder);
448+
449+
$repositoryProphecy = $this->prophesize(EntityRepository::class);
450+
$repositoryProphecy->createQueryBuilder('o')->shouldBeCalled()->willReturn($queryBuilder->reveal());
451+
452+
$rDummyManagerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal());
453+
454+
list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]);
455+
456+
$dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory);
457+
458+
$context = ['property' => 'id', 'identifiers' => [['id', Dummy::class, true], ['relatedDummies', RelatedDummy::class, true]], 'collection' => false];
459+
460+
$this->assertEquals($result, $dataProvider->getSubresource(RelatedDummy::class, ['id' => 1, 'relatedDummies' => 2], $context));
461+
}
366462
}

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)