Symfony2: Deserializing request content right into controller arguments
Matthias Noback
Based on the list of most popular search results leading to my blog, using Symfony2 for building some kind of webservice seems to be quite “hot”. It also seems many people are struggling with the question how they should serialize their content to and from XML and/or JSON. Two beautiful bundles are already available for this, FOSRestBundle and JMSSerializerBundle. Both will help you a lot with this issue, by providing transparent conversion between formats for both the request content and the response body. Nevertheless, a few things are missing. For example, I want to really serialize and deserialize objects, for example, when I send a request with a comment (or part of it) like this:
<?xml version="1.0" encoding="UTF-8"?>
<comment>
<from><![CDATA[Matthias Noback]]></from>
<created_at><![CDATA[2012-02-28T22:10:52+01:00]]></created_at>
</comment>"
I want this to become immediately available in my controller as a Comment
object (be it an entity, or more safe, some kind of a Data Transfer Object):
class DemoController
{
public function createCommentAction(Comment $comment)
{
// ...
}
}
Now I can do whatever I want with the Comment
object, without any inline deserialization of the request
content.
Modify controller arguments
What we need is a way to modify the controller arguments. This may be done by setting attributes
on the Request
object. In fact, this is how the framework itself does it: if your controller has an argument which is type-hinted as a Request
it will magically have as it’s value the current Request
object.
Now in the case of a comment sent as XML, we want the request content to be serialized into a Comment
Data Transfer Object, that should be available as the value of a controller argument. We can accomplish this by creating an event listener, which hooks into the “kernel.controller” event. This event is fired before the controller is called, and every listener receives a FilterControllerEvent
object. Inside the listener, we can set an extra attribute on the current Request
object,
which will later replace the argument of the controller with the name of the attribute (the framework will take care of this in it’s ControllerResolver
).
Our event listener, which tries to transform XML to Comment
objects, needs the Serializer
from the JMSSerializerBundle (please install it first, see the documentation of this bundle for instructions). We will pass the Serializer
as a constructor argument.
Using the Serializer
, the listener will try to convert XML data to a Comment
object, and then use it to populate the Request
attribute called “comment”.
namespace Matthias\RestBundle\EventListener;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use JMS\SerializerBundle\Serializer\Serializer;
use JMS\SerializerBundle\Exception\XmlErrorException;
use Matthias\RestBundle\DataTransferObject\Comment;
class ControllerArgumentsListener
{
private $serializer;
public function __construct(Serializer $serializer)
{
$this->serializer = $serializer;
}
public function onKernelController(FilterControllerEvent $event)
{
$request = $event->getRequest();
// the controller is an attribute of the request
$controller = $request->attributes->get('_controller');
// controller looks like class::method, we need array(class, method)
$controller = explode('::', $controller);
try {
new \ReflectionParameter($controller, 'comment');
}
catch (\ReflectionException $e) {
// no controller argument named "comment" was found
return;
}
if ($request->attributes->has('comment')) {
// comment attribute already exists, do not overwrite it
return;
}
try
{
// deserialize the request content into a Comment object
$comment = $this->serializer->deserialize(
$request->getContent(),
'Matthias\\RestBundle\\DataTransferObject\\Comment',
'xml'
);
}
catch (XmlErrorException $e)
{
// we've tried, but it didn't work out
return;
}
// set the comment attribute on the request,
// this will be used as the $comment argument of the controller
$request->attributes->set('comment', $comment);
}
}
As you can see, this implementation is quite specific, namely for transforming XML request content to a Comment
object, which by the way looks like this:
namespace Matthias\RestBundle\DataTransferObject;
use JMS\SerializerBundle\Annotation\XmlRoot;
use JMS\SerializerBundle\Annotation\Type;
/**
* @XmlRoot("comment")
*/
class Comment
{
/**
* @Type("string")
*/
private $from;
/**
* @Type("DateTime")
*/
private $createdAt;
public function getFrom()
{
return $this->from;
}
/**
* @return \DateTime
*/
public function getCreatedAt()
{
return $this->createdAt;
}
}
The class definition contains the necessary directions for the serializer for serializing to XML and deserializing to a Comment
object. Of course this may freely be extended.
To make all this work, we only need to add the event listener to our list of service definitions, and tag the service as a “kernel.event_listener”, listening to the “kernel.controller” event:
<?xml version="1.0" ?>
<container xmlns="https://symfony.com/schema/dic/services"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="matthias.controller_listener"
class="Matthias\RestBundle\EventListener\ControllerArgumentsListener">
<tag name="kernel.event_listener" event="kernel.controller" method="onKernelController" />
<argument type="service" id="serializer" />
</service>
</services>
</container>
Everything should work now! In case you wish to test the conversion, you may try some unctional testing using PHPUnit:
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class DemoControllerTest extends WebTestCase
{
public function testCreateComment()
{
$content = <<<EOF
<?xml version="1.0" encoding="UTF-8"?>
<comment>
<from><![CDATA[Matthias Noback]]></from>
<created_at><![CDATA[%now%]]></created_at>
</comment>
EOF;
$content = str_replace('%now%', date('c'), $content);
$server = array(
'HTTP_ACCEPT' => 'text/xml',
'HTTP_CONTENT_TYPE' => 'text/xml; charset=UTF-8',
);
$client = $this->createClient();
$client->request(
'POST', '/demo/create-comment',
array(), array(),
$server, $content
);
}
}
Closing remarks
It is not a very difficult task to refactor the event listener and make it more general. Also, it should check if the client accepts XML, by reading and parsing the Accept
and Content-Type
header of the request (FOSRestBundle provides excellent ways to accomplish this).