Skip to content

Commit 66edd3e

Browse files
committed
Add payload param converter
1 parent a12ac48 commit 66edd3e

File tree

11 files changed

+298
-11
lines changed

11 files changed

+298
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 2.6.x-dev
44

55
* MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144)
6+
* Allow controller argument with a name different from `$data` thanks to a param converter (requires [`sensio/framework-extra-bundle](https://packagist.org/packages/sensio/framework-extra-bundle))
67

78
## 2.5.1
89

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"fig/link-util": "^1.0",
1919
"psr/cache": "^1.0",
2020
"psr/container": "^1.0",
21+
"sensio/framework-extra-bundle": "^5.0",
2122
"symfony/http-foundation": "^4.3.6 || ^5.0",
2223
"symfony/http-kernel": "^4.3.7 || ^5.0",
2324
"symfony/property-access": "^3.4 || ^4.0 || ^5.0",

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Doctrine\Common\Annotations\Annotation;
3636
use phpDocumentor\Reflection\DocBlockFactoryInterface;
3737
use Ramsey\Uuid\Uuid;
38+
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
3839
use Symfony\Component\BrowserKit\AbstractBrowser;
3940
use Symfony\Component\Cache\Adapter\ArrayAdapter;
4041
use Symfony\Component\Config\FileLocator;
@@ -117,6 +118,7 @@ public function load(array $configs, ContainerBuilder $container): void
117118
$this->registerElasticsearchConfiguration($container, $config, $loader);
118119
$this->registerDataTransformerConfiguration($container);
119120
$this->registerSecurityConfiguration($container, $loader);
121+
$this->registerParamConverterConfiguration($container, $loader);
120122

121123
$container->registerForAutoconfiguration(DataPersisterInterface::class)
122124
->addTag('api_platform.data_persister');
@@ -632,4 +634,11 @@ private function registerSecurityConfiguration(ContainerBuilder $container, XmlF
632634
$loader->load('security.xml');
633635
}
634636
}
637+
638+
private function registerParamConverterConfiguration(ContainerBuilder $container, XmlFileLoader $loader): void
639+
{
640+
if (interface_exists(ParamConverterInterface::class)) {
641+
$loader->load('param_converter.xml');
642+
}
643+
}
635644
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\ParamConverter;
4+
5+
use ApiPlatform\Core\EventListener\ToggleableDeserializationTrait;
6+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
7+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
8+
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
9+
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
10+
use Symfony\Component\HttpFoundation\Request;
11+
12+
class PayloadParamConverter implements ParamConverterInterface
13+
{
14+
use ToggleableDeserializationTrait;
15+
16+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory)
17+
{
18+
$this->resourceMetadataFactory = $resourceMetadataFactory;
19+
}
20+
21+
public function apply(Request $request, ParamConverter $configuration)
22+
{
23+
$class = $configuration->getClass();
24+
25+
if (null === $class) {
26+
throw new \InvalidArgumentException(sprintf(
27+
'%s only supports parameters with class type declaration.',
28+
self::class
29+
));
30+
}
31+
32+
$attributes = RequestAttributesExtractor::extractAttributes($request);
33+
34+
if (!$this->isRequestToDeserialize($request, $attributes)) {
35+
return false;
36+
}
37+
38+
$data = $request->attributes->get('data');
39+
40+
if (!$data instanceof $class || get_class($data) !== $attributes['resource_class']) {
41+
return false;
42+
}
43+
44+
$request->attributes->set($configuration->getName(), $data);
45+
46+
return true;
47+
}
48+
49+
public function supports(ParamConverter $configuration)
50+
{
51+
return null !== $configuration->getClass();
52+
}
53+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
<service id="api_platform.payload_param_converter" class="ApiPlatform\Core\Bridge\Symfony\Bundle\ParamConverter\PayloadParamConverter" public="false">
9+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
10+
11+
<tag name="request.param_converter" converter="api_platform.payload" priority="-10" />
12+
</service>
13+
</services>
14+
15+
</container>

src/EventListener/DeserializeListener.php

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
use ApiPlatform\Core\Api\FormatMatcher;
1717
use ApiPlatform\Core\Api\FormatsProviderInterface;
1818
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
19-
use ApiPlatform\Core\Metadata\Resource\ToggleableOperationAttributeTrait;
2019
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
2120
use ApiPlatform\Core\Util\RequestAttributesExtractor;
2221
use Symfony\Component\HttpFoundation\Request;
@@ -32,7 +31,7 @@
3231
*/
3332
final class DeserializeListener
3433
{
35-
use ToggleableOperationAttributeTrait;
34+
use ToggleableDeserializationTrait;
3635

3736
public const OPERATION_ATTRIBUTE_KEY = 'deserialize';
3837

@@ -69,15 +68,9 @@ public function __construct(SerializerInterface $serializer, SerializerContextBu
6968
public function onKernelRequest(RequestEvent $event): void
7069
{
7170
$request = $event->getRequest();
72-
$method = $request->getMethod();
73-
74-
if (
75-
'DELETE' === $method
76-
|| $request->isMethodSafe()
77-
|| !($attributes = RequestAttributesExtractor::extractAttributes($request))
78-
|| !$attributes['receive']
79-
|| $this->isOperationAttributeDisabled($attributes, self::OPERATION_ATTRIBUTE_KEY)
80-
) {
71+
$attributes = RequestAttributesExtractor::extractAttributes($request);
72+
73+
if (!$this->isRequestToDeserialize($request, $attributes)) {
8174
return;
8275
}
8376

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace ApiPlatform\Core\EventListener;
4+
5+
use ApiPlatform\Core\Metadata\Resource\ToggleableOperationAttributeTrait;
6+
use Symfony\Component\HttpFoundation\Request;
7+
8+
trait ToggleableDeserializationTrait
9+
{
10+
use ToggleableOperationAttributeTrait;
11+
12+
private function isRequestToDeserialize(Request $request, array $attributes)
13+
{
14+
return
15+
'DELETE' !== $request->getMethod()
16+
&& !$request->isMethodSafe(false)
17+
&& [] !== $attributes
18+
&& $attributes['receive']
19+
&& !$this->isOperationAttributeDisabled($attributes, DeserializeListener::OPERATION_ATTRIBUTE_KEY)
20+
;
21+
}
22+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\ParamConverter;
4+
5+
class NotResource
6+
{
7+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<?php
2+
3+
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\ParamConverter;
4+
5+
use ApiPlatform\Core\Bridge\Symfony\Bundle\ParamConverter\PayloadParamConverter;
6+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
7+
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
8+
use PHPUnit\Framework\TestCase;
9+
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
10+
use Symfony\Component\HttpFoundation\Request;
11+
12+
class PayloadParamConverterTest extends TestCase
13+
{
14+
public function testItSupportsParameterWithClassAsTypeDeclaration()
15+
{
16+
$converter = $this->createParamConverter();
17+
18+
$configuration = new ParamConverter(['class' => ResourceImplementation::class]);
19+
20+
$this->assertTrue($converter->supports($configuration));
21+
}
22+
23+
public function testItDoesNotSupportParameterWithoutClassAsTypeDeclaration()
24+
{
25+
$converter = $this->createParamConverter();
26+
27+
$configuration = new ParamConverter([]);
28+
29+
$this->assertFalse($converter->supports($configuration));
30+
}
31+
32+
public function testItDisallowsApplyingToUnsupportedParameter()
33+
{
34+
$converter = $this->createParamConverter();
35+
36+
$request = $this->createRequest('PUT');
37+
$configuration = new ParamConverter([]);
38+
39+
$this->expectException(\InvalidArgumentException::class);
40+
41+
$converter->apply($request, $configuration);
42+
}
43+
44+
/**
45+
* @dataProvider provideUnsupportedRequests
46+
*/
47+
public function testItDoesNotApplyToUnsupportedRequest(Request $request)
48+
{
49+
$converter = $this->createParamConverter();
50+
51+
$configuration = new ParamConverter(['class' => ResourceImplementation::class]);
52+
53+
$this->assertNotApplied($converter, $request, $configuration);
54+
}
55+
56+
public function provideUnsupportedRequests()
57+
{
58+
yield 'GET request' => [$this->createRequest('GET')];
59+
60+
yield 'HEAD request' => [$this->createRequest('HEAD')];
61+
62+
yield 'OPTIONS request' => [$this->createRequest('OPTIONS')];
63+
64+
yield 'TRACE request' => [$this->createRequest('TRACE')];
65+
66+
yield 'DELETE request' => [$this->createRequest('DELETE')];
67+
68+
$requestWithoutAttributes = $this->createRequest();
69+
$requestWithoutAttributes->attributes->replace([]);
70+
yield 'request without attributes' => [$requestWithoutAttributes];
71+
72+
yield 'request with receive=false' => [$this->createRequest('PUT', [
73+
'_api_receive' => false,
74+
])];
75+
76+
yield 'request on operation with deserialization disabled' => [$this->createRequest('PUT', [
77+
'_api_item_operation_name' => 'update_no_deserialize',
78+
])];
79+
80+
yield 'request with data null' => [$this->createRequest('PUT', [
81+
'data' => null,
82+
])];
83+
84+
yield 'request with data of unrelated type' => [$this->createRequest('PUT', [
85+
'data' => new NotResource(),
86+
])];
87+
}
88+
89+
public function testItAppliesToRequestWithDataOfExactType()
90+
{
91+
$converter = $this->createParamConverter();
92+
93+
$configuration = new ParamConverter([
94+
'name' => 'foo',
95+
'class' => ResourceImplementation::class,
96+
]);
97+
98+
$request = $this->createRequest();
99+
100+
$this->assertApplied($converter, $request, $configuration);
101+
}
102+
103+
public function testItAppliesToRequestWithDataOfChildType()
104+
{
105+
$converter = $this->createParamConverter();
106+
107+
$configuration = new ParamConverter([
108+
'name' => 'foo',
109+
'class' => ResourceInterface::class,
110+
]);
111+
112+
$request = $this->createRequest();
113+
114+
$this->assertApplied($converter, $request, $configuration);
115+
}
116+
117+
private function assertApplied(PayloadParamConverter $converter, Request $request, ParamConverter $configuration)
118+
{
119+
$attributes = $request->attributes->all();
120+
$attributes[$configuration->getName()] = $attributes['data'];
121+
122+
$this->assertTrue($converter->apply($request, $configuration));
123+
$this->assertSame($attributes, $request->attributes->all());
124+
}
125+
126+
private function assertNotApplied(PayloadParamConverter $converter, Request $request, ParamConverter $configuration)
127+
{
128+
$attributes = $request->attributes->all();
129+
130+
$this->assertFalse($converter->apply($request, $configuration));
131+
$this->assertSame($attributes, $request->attributes->all());
132+
}
133+
134+
private function createParamConverter()
135+
{
136+
$metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class);
137+
$metadataFactory->create(ResourceImplementation::class)->willReturn(new ResourceMetadata(
138+
ResourceImplementation::class,
139+
null,
140+
null,
141+
[
142+
'update' => [
143+
'method' => 'PUT',
144+
],
145+
'update_no_deserialize' => [
146+
'method' => 'PUT',
147+
'deserialize' => false,
148+
],
149+
],
150+
[
151+
'create' => [
152+
'method' => 'POST',
153+
],
154+
]
155+
));
156+
157+
return new PayloadParamConverter($metadataFactory->reveal());
158+
}
159+
160+
private function createRequest($method = 'PUT', array $attributes = [])
161+
{
162+
$request = Request::create('/foo', $method);
163+
$request->attributes->replace($attributes + [
164+
'_api_resource_class' => ResourceImplementation::class,
165+
'_api_receive' => true,
166+
'_api_item_operation_name' => 'update',
167+
'data' => new ResourceImplementation(),
168+
]);
169+
170+
return $request;
171+
}
172+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\ParamConverter;
4+
5+
class ResourceImplementation implements ResourceInterface
6+
{
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\ParamConverter;
4+
5+
interface ResourceInterface
6+
{
7+
}

0 commit comments

Comments
 (0)