Skip to content

Commit e2ed79d

Browse files
committed
Allow abstract resource
1 parent 22ea21b commit e2ed79d

File tree

9 files changed

+461
-12
lines changed

9 files changed

+461
-12
lines changed

Api/ResourceResolverTrait.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ public function guessResource($object, array $context = null, $strict = false)
6767
}
6868

6969
if ($strict && isset($isObject) && $resource->getEntityClass() !== $type) {
70+
if (is_subclass_of($type, $resource->getEntityClass())) {
71+
$resource = $this->resourceCollection->getResourceForEntity($type);
72+
if (null !== $resource) {
73+
return $resource;
74+
}
75+
}
76+
7077
throw new InvalidArgumentException(
7178
sprintf('No resource found for object of type "%s"', $type)
7279
);

DependencyInjection/Compiler/ResourcePass.php

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,41 @@ public function process(ContainerBuilder $container)
4242
continue;
4343
}
4444

45+
$isAbstract = $this->isAbstract($resourceDefinition->getArgument(0));
4546
if (!$resourceDefinition->hasMethodCall('initItemOperations')) {
46-
$resourceDefinition->addMethodCall('initItemOperations', [[
47-
$this->createOperation($container, $serviceId, 'GET', false),
48-
$this->createOperation($container, $serviceId, 'PUT', false),
49-
$this->createOperation($container, $serviceId, 'DELETE', false),
50-
]]);
47+
$methods = $isAbstract ? ['GET', 'DELETE'] : ['GET', 'PUT', 'DELETE'];
48+
$resourceDefinition->addMethodCall('initItemOperations', [$this->createOperations($container, $serviceId, $methods, false)]);
5149
}
5250

5351
if (!$resourceDefinition->hasMethodCall('initCollectionOperations')) {
54-
$resourceDefinition->addMethodCall('initCollectionOperations', [[
55-
$this->createOperation($container, $serviceId, 'GET', true),
56-
$this->createOperation($container, $serviceId, 'POST', true),
57-
]]);
52+
$methods = $isAbstract ? ['GET'] : ['GET', 'POST'];
53+
$resourceDefinition->addMethodCall('initCollectionOperations', [$this->createOperations($container, $serviceId, $methods, true)]);
5854
}
5955
}
6056

6157
$resourceCollectionDefinition->addMethodCall('init', [$resourceReferences]);
6258
}
6359

60+
/**
61+
* Adds a list of operations.
62+
*
63+
* @param ContainerBuilder $container
64+
* @param string $serviceId
65+
* @param array $methods
66+
* @param bool $collection
67+
*
68+
* @return Reference[]
69+
*/
70+
private function createOperations(ContainerBuilder $container, $serviceId, $methods, $collection)
71+
{
72+
$operations = [];
73+
foreach ($methods as $method) {
74+
$operations[] = $this->createOperation($container, $serviceId, $method, $collection);
75+
}
76+
77+
return $operations;
78+
}
79+
6480
/**
6581
* Adds an operation.
6682
*
@@ -94,6 +110,20 @@ private function createOperation(ContainerBuilder $container, $serviceId, $metho
94110
return new Reference($operationId);
95111
}
96112

113+
/**
114+
* Returns if the given class is abstract.
115+
*
116+
* @param string $instanceClass
117+
*
118+
* @return bool
119+
*/
120+
private function isAbstract($instanceClass)
121+
{
122+
$reflectionClass = new \ReflectionClass($instanceClass);
123+
124+
return $reflectionClass->isAbstract();
125+
}
126+
97127
/**
98128
* Gets class of the given definition.
99129
*

JsonLd/Serializer/ItemNormalizer.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,6 @@ public function denormalize($data, $class, $format = null, array $context = [])
199199
}
200200
}
201201

202-
$reflectionClass = new \ReflectionClass($class);
203-
204202
if (isset($data['@id']) && !isset($context['object_to_populate'])) {
205203
$context['object_to_populate'] = $this->iriConverter->getItemFromIri($data['@id']);
206204

@@ -210,9 +208,20 @@ public function denormalize($data, $class, $format = null, array $context = [])
210208
$overrideClass = false;
211209
}
212210

211+
$instanceClass = $overrideClass ? get_class($context['object_to_populate']) : $class;
212+
$reflectionClass = new \ReflectionClass($instanceClass);
213+
if ($reflectionClass->isAbstract()) {
214+
throw new InvalidArgumentException(
215+
sprintf(
216+
'Cannot create an instance of %s from serialized data because it is an abstract resource',
217+
$instanceClass
218+
)
219+
);
220+
}
221+
213222
$object = $this->instantiateObject(
214223
$normalizedData,
215-
$overrideClass ? get_class($context['object_to_populate']) : $class,
224+
$instanceClass,
216225
$context,
217226
$reflectionClass,
218227
$allowedAttributes
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the DunglasApiBundle package.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
use Dunglas\ApiBundle\Annotation\Iri;
16+
use Symfony\Component\Validator\Constraints as Assert;
17+
18+
/**
19+
* AbstractDummy.
20+
*
21+
* @author Jérémy Derussé <[email protected]>
22+
*
23+
* @ORM\Entity
24+
* @ORM\InheritanceType("SINGLE_TABLE")
25+
* @ORM\DiscriminatorColumn(name="discr", type="string", length=16)
26+
* @ORM\DiscriminatorMap({"concrete" = "ConcreteDummy"})
27+
*/
28+
abstract class AbstractDummy
29+
{
30+
/**
31+
* @var int The id.
32+
*
33+
* @ORM\Column(type="integer")
34+
* @ORM\Id
35+
* @ORM\GeneratedValue(strategy="AUTO")
36+
*/
37+
private $id;
38+
/**
39+
* @var string The dummy name.
40+
*
41+
* @ORM\Column
42+
* @Assert\NotBlank
43+
* @Iri("http://schema.org/name")
44+
*/
45+
private $name;
46+
47+
public function getId()
48+
{
49+
return $this->id;
50+
}
51+
52+
public function setName($name)
53+
{
54+
$this->name = $name;
55+
}
56+
57+
public function getName()
58+
{
59+
return $this->name;
60+
}
61+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the DunglasApiBundle package.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
use Symfony\Component\Validator\Constraints as Assert;
16+
17+
/**
18+
* ConcreteDummy.
19+
*
20+
* @author Jérémy Derusse <[email protected]>
21+
*
22+
* @ORM\Entity
23+
*/
24+
class ConcreteDummy extends AbstractDummy
25+
{
26+
/**
27+
* @var string a concrete thing
28+
*
29+
* @ORM\Column
30+
* @Assert\NotBlank
31+
*/
32+
private $instance;
33+
34+
public function setInstance($instance)
35+
{
36+
$this->instance = $instance;
37+
}
38+
39+
public function getInstance()
40+
{
41+
return $this->instance;
42+
}
43+
}

Tests/DependencyInjection/Compiler/ResourcePassTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public function testProcess()
3737

3838
$builtinResourceDefinitionProphecy = $this->prophesize('Symfony\Component\DependencyInjection\Definition');
3939
$builtinResourceDefinitionProphecy->getClass()->willReturn('Dunglas\ApiBundle\Api\Resource')->shouldBeCalled();
40+
$builtinResourceDefinitionProphecy->getArgument(0)->willReturn('stdClass')->shouldBeCalled();
4041
$builtinResourceDefinitionProphecy->hasMethodCall('initItemOperations')->willReturn(true)->shouldBeCalled();
4142
$builtinResourceDefinitionProphecy->addMethodCall('initItemOperations')->shouldNotBeCalled();
4243
$builtinResourceDefinitionProphecy->hasMethodCall('initCollectionOperations', Argument::any())->willReturn(true)->shouldBeCalled();
@@ -50,6 +51,7 @@ public function testProcess()
5051
$decoratedResourceDefinitionProphecy = $this->prophesize('Symfony\Component\DependencyInjection\DefinitionDecorator');
5152
$decoratedResourceDefinitionProphecy->getClass()->willReturn(false)->shouldBeCalled();
5253
$decoratedResourceDefinitionProphecy->getParent()->willReturn('inner_resource')->shouldBeCalled();
54+
$decoratedResourceDefinitionProphecy->getArgument(0)->willReturn('stdClass')->shouldBeCalled();
5355
$decoratedResourceDefinitionProphecy->hasMethodCall('initItemOperations')->willReturn(false)->shouldBeCalled();
5456
$decoratedResourceDefinitionProphecy->addMethodCall('initItemOperations', Argument::type('array'))->shouldBeCalled();
5557
$decoratedResourceDefinitionProphecy->hasMethodCall('initCollectionOperations')->willReturn(false)->shouldBeCalled();

features/crud_abstract.feature

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
Feature: Create-Retrieve-Update-Delete on abstract resource
2+
In order to use an hypermedia API
3+
As a client software developer
4+
I need to be able to retrieve, create, update and delete JSON-LD encoded resources even if they are abstract.
5+
6+
@createSchema
7+
Scenario: Create a concrete resource
8+
When I send a "POST" request to "/concrete_dummies" with body:
9+
"""
10+
{
11+
"instance": "Concrete",
12+
"name": "My Dummy"
13+
}
14+
"""
15+
Then the response status code should be 201
16+
And the response should be in JSON
17+
And the header "Content-Type" should be equal to "application/ld+json"
18+
And the JSON should be equal to:
19+
"""
20+
{
21+
"@context": "/contexts/ConcreteDummy",
22+
"@id": "/concrete_dummies/1",
23+
"@type": "ConcreteDummy",
24+
"instance": "Concrete",
25+
"name": "My Dummy"
26+
}
27+
"""
28+
29+
Scenario: Get a resource
30+
When I send a "GET" request to "/abstract_dummies/1"
31+
Then the response status code should be 200
32+
And the response should be in JSON
33+
And the header "Content-Type" should be equal to "application/ld+json"
34+
And the JSON should be equal to:
35+
"""
36+
{
37+
"@context": "/contexts/ConcreteDummy",
38+
"@id": "/concrete_dummies/1",
39+
"@type": "ConcreteDummy",
40+
"instance": "Concrete",
41+
"name": "My Dummy"
42+
}
43+
"""
44+
45+
Scenario: Get a collection
46+
When I send a "GET" request to "/abstract_dummies"
47+
Then the response status code should be 200
48+
And the response should be in JSON
49+
And the header "Content-Type" should be equal to "application/ld+json"
50+
And the JSON should be equal to:
51+
"""
52+
{
53+
"@context": "/contexts/AbstractDummy",
54+
"@id": "/abstract_dummies",
55+
"@type": "hydra:PagedCollection",
56+
"hydra:totalItems": 1,
57+
"hydra:itemsPerPage": 3,
58+
"hydra:firstPage": "/abstract_dummies",
59+
"hydra:lastPage": "/abstract_dummies",
60+
"hydra:member": [
61+
{
62+
"@id":"/concrete_dummies/1",
63+
"@type":"ConcreteDummy",
64+
"instance": "Concrete",
65+
"name": "My Dummy"
66+
}
67+
],
68+
"hydra:search": {
69+
"@type": "hydra:IriTemplate",
70+
"hydra:template": "\/abstract_dummies{?id,name,order[id],order[name]}",
71+
"hydra:variableRepresentation": "BasicRepresentation",
72+
"hydra:mapping": [
73+
{
74+
"@type": "IriTemplateMapping",
75+
"variable": "id",
76+
"property": "id",
77+
"required": false
78+
},
79+
{
80+
"@type": "IriTemplateMapping",
81+
"variable": "name",
82+
"property": "name",
83+
"required": false
84+
},
85+
{
86+
"@type": "IriTemplateMapping",
87+
"variable": "order[id]",
88+
"property": "id",
89+
"required": false
90+
},
91+
{
92+
"@type": "IriTemplateMapping",
93+
"variable": "order[name]",
94+
"property": "name",
95+
"required": false
96+
}
97+
]
98+
}
99+
}
100+
"""
101+
102+
Scenario: Update a concrete resource
103+
When I send a "PUT" request to "/concrete_dummies/1" with body:
104+
"""
105+
{
106+
"@id": "/concrete_dummies/1",
107+
"instance": "Become real",
108+
"name": "A nice dummy"
109+
}
110+
"""
111+
Then the response status code should be 200
112+
And the response should be in JSON
113+
And the header "Content-Type" should be equal to "application/ld+json"
114+
And the JSON should be equal to:
115+
"""
116+
{
117+
"@context": "/contexts/ConcreteDummy",
118+
"@id": "/concrete_dummies/1",
119+
"@type": "ConcreteDummy",
120+
"instance": "Become real",
121+
"name": "A nice dummy"
122+
}
123+
"""
124+
125+
@dropSchema
126+
Scenario: Delete a resource
127+
When I send a "DELETE" request to "/abstract_dummies/1"
128+
Then the response status code should be 204
129+
And the response should be empty

features/fixtures/TestApp/config/config.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ services:
112112
- "@my_relation_embedder_resource.item_operation.custom_get"
113113
tags: [ { name: "api.resource" } ]
114114

115+
my_abstract_dummy_resource:
116+
parent: "api.resource"
117+
arguments: [ "Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity\AbstractDummy" ]
118+
calls:
119+
- method: "initFilters"
120+
arguments: [ [ "@my_dummy_resource.search_filter", "@my_dummy_resource.order_filter", "@my_dummy_resource.date_filter" ] ]
121+
tags: [ { name: "api.resource" } ]
122+
123+
my_concrete_dummy_resource:
124+
parent: "api.resource"
125+
arguments: [ "Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity\ConcreteDummy" ]
126+
tags: [ { name: "api.resource" } ]
127+
115128
custom_resource:
116129
parent: "api.resource"
117130
class: "Dunglas\ApiBundle\Tests\Behat\TestBundle\Api\CustomResource"

0 commit comments

Comments
 (0)