Skip to content
This repository has been archived by the owner on Feb 24, 2023. It is now read-only.

[ParamConverters] Object ParamConverter #137

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
123 changes: 123 additions & 0 deletions Request/ParamConverter/ObjectParamConverter.php
@@ -0,0 +1,123 @@
<?php

/*
* This file is part of the Symfony framework.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpFoundation\Request;

/**
* Converts arrays into objects by mapping keys onto
* constructor arguments. By restricing access to constructor
* parameters only, security problems can be avoided and a central
* location for filtering input data is available.
*
* Data is only ever retrieved from the query part of the request,
* use the form framework for transforming POST data into objects.
*
* Converter can be used to generate criteria/filtering struct objects
* that are passed to the model layer.
*
* Conversion works recursivly and with a special case for DateTime objects.
*
* Important note: This converter has to run AFTER any persistent object
* converter such as the DoctrineParamConverter, so that data from the database
* is preferred over user input data. Otherwise attackers could snuck in
* objects to operate on. This is why only constructor arguments are mapped.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
*/
class ObjectParamConverter implements ParamConverterInterface
{
public function apply(Request $request, ConfigurationInterface $configuration)
{
$param = $configuration->getName();
$options = $configuration->getOptions();
$part = isset($options['part']) ? $options['part'] : 'query';

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concept of converters is to convert request attributes to objects. I'm not sure that converting other "parts" of the request is something we want to support (cookies is probably out of question I think).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually just use it to access query, something similiar like https://github.com/FriendsOfSymfony/FOSRestBundle/blob/master/Request/ParamFetcher.php

However domain specific objects instead of the generic object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also for example http://php-and-symfony.matthiasnoback.nl/2012/03/symfony2-creating-a-paramconverter-for-deserializing-request-content/ is also very useful.

I think with named converters this actually makes sense. Say we don't make the object converter part of the iteration, but only allow to use it when explicitly named, in that case developers know what they are doing, when they use it.

if (!in_array($part, array('attributes', 'query', 'request', 'cookies'))) {
throw new \RuntmeException("Invalid part to retrieve data from.");
}

if ($request->$part->has($param)) {
$data = $request->$part->get($param, array());
} else {
$data = array();
}

$class = $configuration->getClass();
$object = $this->convertClass($class, $data);

$request->attributes->set($param, $object);

return true;
}

private function convertClass($class, $data)
{
$reflClass = new \ReflectionClass($class);

if ($reflClass->hasMethod('__set_state')) {
return $class::__set_state($data);
}

$constructor = $reflClass->getConstructor();

return $reflClass->newInstanceArgs($this->convertClassArguments($constructor, $data));
}

private function convertClassArguments($constructor, $data)
{
$args = array();

if (!$constructor) {
return array();
}

if (is_scalar($data)) {
return array($data);
}

foreach ($constructor->getParameters() as $parameter) {
$argValue = null;
$parameterName = $parameter->getName();

if (isset($data[$parameterName])) {
$argValue = $data[$parameterName];

if ($parameter->getClass()) {
if ($parameter->getClass()->getName() == "DateTime") {
$argValue = new \DateTime($argValue);
} else if (is_array($argValue)) {
$argValue = $this->convertClass($parameter->getClass()->getName(), $argValue);
}
}

} else if ($parameter->isOptional()) {
$argValue = $parameter->getDefaultValue();
} else {
throw new HttpException(400, "Missing parameter '" . $parameterName . "'");
}

$args[] = $argValue;
}

return $args;
}

public function supports(ConfigurationInterface $configuration)
{
return $configuration->getClass() !== null;
}
}

5 changes: 5 additions & 0 deletions Resources/config/converters.xml
Expand Up @@ -9,6 +9,7 @@
<parameter key="sensio_framework_extra.converter.manager.class">Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterManager</parameter>
<parameter key="sensio_framework_extra.converter.doctrine.class">Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\DoctrineParamConverter</parameter>
<parameter key="sensio_framework_extra.converter.datetime.class">Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\DateTimeParamConverter</parameter>
<parameter key="sensio_framework_extra.converter.object.class">Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ObjectParamConverter</parameter>
</parameters>

<services>
Expand All @@ -27,5 +28,9 @@
<service id="sensio_framework_extra.converter.datetime" class="%sensio_framework_extra.converter.datetime.class%">
<tag name="request.param_converter" converter="datetime" />
</service>

<service id="sensio_framework_extra.converter.object" class="%sensio_framework_extra.converter.object.class%">
<tag name="request.param_converter" priority="-10" name="object" />
</service>
</services>
</container>
62 changes: 60 additions & 2 deletions Resources/doc/annotations/converters.rst
Expand Up @@ -54,8 +54,7 @@ To detect which converter is run on a parameter the following process is run:
Built-in Converters
-------------------

The bundle has two built-in converter, the Doctrine one and a DateTime
converter.
The bundle has several built-in converters that are detailed here.

Doctrine Converter
~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -164,6 +163,65 @@ is accepted. You can be stricter with input given through the options::
{
}

Object Converter
----------------

If the Doctrine Converter (or any other Persistent Object Converter) could not
convert an object, you can use the generic object converter to map request data
to an object.

- If the option 'part' is specified with 'request', 'query', 'cookies' or
'attributes' the given part is used as source of data to create the object.
When the option is not given the 'query' part is used automatically.
- If the data is an array, the keys are matched against parameter variable names in the
target object constructor.
- If the data is a scalar value it is assumed to be a one-parameter
constructor.
- If the object has a static ``__set_state`` method it will be used instead
of the constructor to create the object.
- The request data is only matched against the constructor of the target
object to allow the developer to have full control over the accepted user
input.
- The construction is recursive and allows to assemble object graphs. DateTime
is handled as special case. Persistent objects cannot be converted as
children of an object.
- If a non-optional parameter is not part of the request, a HTTP 400 exception
is thrown.

Example::

/**
* @Route("/blog")
* @ParamConverter("criteria", options={"part": "query"})
*/
public function listAction(PostCriteria $criteria)
{
}

class PostCriteria
{
private $page;
private $count;

public function __construct($page = 1, $count = 20)
{
$this->page = $page;
$this->count = $count;
}
}

Example requests for this action could be:

curl http://example.com/blog
curl http://example.com/blog?criteria[page]=4&criteria[count]=50

.. note::

For security reasons the object converter has to run AFTER persistent
object parameter converters such as the DoctrineParam Converter. Otherwise
attackers could inject objects in your action that would normally be
persistent objects and not objects from user input.

Creating a Converter
--------------------

Expand Down
124 changes: 124 additions & 0 deletions Tests/Request/ParamConverter/ObjectParamConverterTest.php
@@ -0,0 +1,124 @@
<?php

namespace Sensio\Bundle\FrameworkExtraBundle\Tests\Request\ParamConverter;

use Symfony\Component\HttpFoundation\Request;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ObjectParamConverter;

class ObjectParamConverterTest extends \PHPUnit_Framework_TestCase
{
private $converter;

public function setUp()
{
$this->converter = new ObjectParamConverter();
}

public function testApply()
{
$request = Request::create('/', 'POST');
$request->query->set('foo', array('bar' => array('foo' => 1), 'baz' => '2012-07-21'));

$config = $this->createConfiguration('foo', __NAMESPACE__ . '\\Foo');

$this->converter->apply($request, $config);

$foo = $request->attributes->get('foo');
$this->assertInstanceOf(__NAMESPACE__ . '\\Foo', $foo);
$this->assertInstanceOf(__NAMESPACE__ . '\\Bar', $foo->bar);
$this->assertInstanceOf('DateTime', $foo->baz);
$this->assertEquals(1, $foo->bar->foo);
}

public function testApplyPartRequest()
{
$request = Request::create('/', 'POST');
$request->request->set('foo', array('bar' => array('foo' => 1), 'baz' => '2012-07-21'));

$config = $this->createConfiguration('foo', __NAMESPACE__ . '\\Foo');
$config->expects($this->once())->method('getOptions')->will($this->returnValue(array('part' => 'request')));

$this->converter->apply($request, $config);

$foo = $request->attributes->get('foo');
$this->assertInstanceOf(__NAMESPACE__ . '\\Foo', $foo);
$this->assertInstanceOf(__NAMESPACE__ . '\\Bar', $foo->bar);
$this->assertInstanceOf('DateTime', $foo->baz);
$this->assertEquals(1, $foo->bar->foo);
}

public function testApplySetState()
{
$request = Request::create('/', 'POST');
$request->query->set('foo', array('foo' => 1, 'bar' => 2));

$config = $this->createConfiguration('foo', __NAMESPACE__ . '\\Baz');

$this->converter->apply($request, $config);

$baz = $request->attributes->get('foo');
$this->assertInstanceOf(__NAMESPACE__ . '\\Baz', $baz);

$this->assertEquals(1, $baz->foo);
$this->assertEquals(2, $baz->bar);
}

public function createConfiguration($name, $class, array $options = null)
{
$config = $this->getMock(
'Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationInterface', array(
'getClass', 'getAliasName', 'getOptions', 'getName'
));
if ($options !== null) {
$config->expects($this->once())
->method('getOptions')
->will($this->returnValue($options));
}
$config->expects($this->any())
->method('getClass')
->will($this->returnValue($class));
$config->expects($this->any())
->method('getName')
->will($this->returnValue($name));

return $config;
}
}

class Foo
{
public $bar;
public $baz;

public function __construct(Bar $bar, \DateTime $baz)
{
$this->bar = $bar;
$this->baz = $baz;
}
}

class Bar
{
public $foo;

public function __construct($foo)
{
$this->foo = $foo;
}
}

class Baz
{
public $foo;
public $bar;

static public function __set_state(array $vars)
{
$baz = new self();
$baz->foo = $vars['foo'];
$baz->bar = $vars['bar'];

return $baz;
}
}