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).
This kind of technique becomes really popular before but for sure there might be some other people who already made some improvements that can help them in attaining a better output in terms of deserializing request content right into controller arguments.
You casually mentioned" Data Transfer Object", indicating that this is know territory for you.
The reason I found your post is that I was looking for de-serializing / persisting nested objects. for instance company has address. On the client this would be flattened.
Some kind of logic in between the domain model and the channel to the client would translate between the domain model and the client viewmodel. But the DTO docs state that the DTO itself cannot have any business logic.
In this post I found another example which does some mapping. Is that the way to go then?
Thanks for your comment. This really is an area of great interest to me. In my opinion, the way to go would be to use mappers to define the rules for creating a DTO based on a domain object. This object can be serialized using for example the serializer from the jmsserializerbundle, which supports recursive serializing of objects to XML or JSON. Depending on the kind of API you develop, you may also define another mapper for the inverse transformation: from request content to DTO. I would be happy to exchange ideas about this subject, and answer any question you have. I have the feeling there is not too many experience in this area in the php world. A very good resource is the book Service design patterns and the corresponding website. Good luck.
Hi Matthias,
I am trying to set this up, but I keep getting an error saying: Fatal error: Class 'BIM\BimfsBundle\EventListener\ControllerArgumentsListener' not found in /Library/WebServer/Documents/BIMfs-v2/app/cache/test/appTestDebugProjectContainer.php on line 403
Is there some step I am missing? I have tried warming up the cache but it seems it is not be registering my namespace.
Thanks,
Cory
Hi Cory, this looks like a problem with autoloading the file: have you registered the namespace "BIM" in
app/autoload.php
? And do the namespaces and the path match exactly?BIM\BimfsBundle\EventListener\ControllerArgumentsListener
should be in/src/BIM/BimfsBundle/EventListener/ControllerArgumentsListener.php
Good luck!
Hi Matthias,
I did not have the bundle in the auto loader, however adding it does not resolve the error. I wish there was a easy way to see what bundles where loaded by symfony2 similiar to how I can check routes via the console.
Here is what I added, but the issue remains
$loader->registerNamespaces(array(
'BIM' => __DIR__.'/../src',
));
-Cory
After much pain and anguish, I discovered I had a typo in a folder name.
Thanks,
Cory
Do you think that this is something that could be moved into FOS bundle in order that it can handle this case?
Hi Jonathan,
I think it could be added to the bundle, though I am not sure what should be the exact way. In the meantime I have made this all a lot more general, using annotation. Johannes Schmitt suggested to use a ParamConverter (from the SensioFrameworkExtraBundle). The concept certainly has great potential, especially since you can also add validation to the objects that were deserialized from the request content - something I was looking for since the body listener from the bundle returns simple arrays.