In my previous post I wrote about a listener that deserializes the request content and replaces controller arguments with the result. Johannes Schmitt made the suggestion to use a ParamConverter
for this. This of course makes sense: currently there is only a ParamConverter
for converting some "id" argument into an entity with the same id (see the documentation for @ParamConverter.
In this post I will show you how to create your own ParamConverter
and how we can specialize it in deserializing request content.
So, we still have the createCommentAction
in the DemoController
from the previous post:
class DemoController
{
public function createCommentAction(Comment $comment)
{
// ...
}
}
And also the very simple Comment
class:
<?php
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;
}
public function __construct()
{
//$this->createdAt = new \DateTime;
}
}
The ParamConverter itself
First, let's create the ParamConverter
itself. It should implement the ParamConverterInterface
, which means it should have a supports()
method. This method receives a configuration object which it can use to determine if the ParamConverter can provide the right value for a certain argument. The ParamConverter
should also implement apply()
, which receives the current Request
object and again the configuration object. Using these two (and probably some service you injected using constructor or setter arguments) the apply()
method should try to supply the action with the right argument.
In our case the ParamConverter
should convert the request content (which is XML) to a full Comment
object.
namespace Matthias\RestBundle\Request\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use JMS\SerializerBundle\Serializer\SerializerInterface;
use JMS\SerializerBundle\Exception\XmlErrorException;
class SerializedParamConverter implements ParamConverterInterface
{
private $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
public function supports(ConfigurationInterface $configuration)
{
if (!$configuration->getClass()) {
return false;
}
// for simplicity, everything that has a "class" type hint is supported
return true;
}
public function apply(Request $request, ConfigurationInterface $configuration)
{
$class = $configuration->getClass();
try {
$object = $this->serializer->deserialize(
$request->getContent(),
$class,
'xml'
);
}
catch (XmlErrorException $e) {
throw new NotFoundHttpException(sprintf('Could not deserialize request content to object of type "%s"',
$class));
}
// set the object as the request attribute with the given name
// (this will later be an argument for the action)
$request->attributes->set($configuration->getName(), $object);
}
}
Now we should register the SerializedParamConverter
as a service:
<?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.serialized_param_converter"
class="Matthias\RestBundle\Request\ParamConverter\SerializedParamConverter">
<argument type="service" id="serializer" />
<tag name="request.param_converter" priority="-100" />
</service>
</services>
</container>
Note that we need to give some low priority to our converter, since it supports any argument with a "class" type hint. If we don't provide the priority
argument, the DoctrineParamConverter
may never be called for arguments that should in fact be converted to an entity.
As it says in the documentation for the @ParamConverter
annotation, if you use type hinting, you can omit the special annotation. The SerializedParamConverter
will be called automatically for any unresolved action argument.
You can test all this by the same method as described in my previous post, but for completeness, my not very elegant yet pragmatic test looks like this:
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Matthias\RestBundle\DataTransferObject\Comment;
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;
$server = array(
'HTTP_ACCEPT' => 'text/xml',
'HTTP_CONTENT_TYPE' => 'text/xml; charset=UTF-8',
);
$content = str_replace('%now%', date('c'), $content);
$client = $this->createClient();
$client->request('POST', '/demo/create-comment', array(), array(), $server, $content);
var_dump($client->getResponse()->getContent());
exit;
}
}
And the controller itself looks like
namespace Matthias\RestBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\HttpFoundation\Response;
use Matthias\RestBundle\DataTransferObject\Comment;
class DemoController extends Controller
{
/**
* @param Matthias\RestBundle\DataTransferObject\Comment $comment
* @Route("/demo/create-comment", name="demo_create_comment")
* @Method("POST")
*/
public function createCommentAction(Comment $comment)
{
return new Response(print_r($comment, true));
}
}
Hi, can this be used with silex? Please, as I am having problems converting request objects to entity. Thanks
Hi Matthias!!
Thanks a lot for your post! Can I creating a ParamConverter for deserializing request when the content is an array json? In your case, imagine that you have an array json of Comments.
Once again, thank you very much.
(Excuse me, for my english)
And what to do if Doctrine throws exception cause can't deseriralize JSON string?
Hi Dmitry, it would indeed be a good idea to modify the exception handling part. In the first place, you should add multiple catches, one for XML errors, but one for additional serialization errors. Also, it is not such a good idea to return 404 Not found, when actually it should be 400 Bad request.
You forgot to add "return true;" statement to SerializedParamConverter::supports method.
It is there, right?
Yes, I think so. Because here https://github.com/sensio/S... we see how it works.
If it won't return true, for example, DoctrineParamConverter will be applied and exception will be thrown because DoctrineParam... can't deserialize XML or JSON string.
Excuse me, for my english
Hi Matthias!
Firstly we have to say - you are doing very good job over here :-)
We're making Restful API on Symfony2 with FOSRestBundle and we also thought about specialized ParamConverter for converting request content body to Objects.
We wanted to contribute in FosRestBundle with such ParamConverter + needed other changes in Bundle structure but you were first with 'the code'.
Are you planning to push it to FOSRestBundle or can you allow us to use your ParamConverter code (with some changes which includes automatic content-type recognition) with added comment like 'Based on Matthias Noback's Param Converter from http://...'?
Greetings from Poland! :D
Hi Antoni,
Thanks, really happy to hear about that. I've had this on my "to do" list for quite some time, but since you are ready to contribute right now, go ahead! It would be nice if you would add a small comment and a link to my blog.
Good luck!
Done!
https://github.com/FriendsO...
Argh that first contribution wasn't easy :D (Just take a look at history of my account https://github.com/orfin - 6x attempts to make 'good commits')
It looks like on Windows you have to config git that way:
$ git config --global core.autocrlf true
instead of "input" recommended by Symfony2 devs [http://symfony.com/doc/curr...] :/
i would be very happy if this could be either part of framework extra bundle or jms serializer bundle, only activated when the other bundle is also active. I need that for so many projects :-)
Hi, I'm happy to hear that. I would certainly encourage making this a part of some other bundle (probably FOSRestBundle, since it already uses JMSSerializerBundle and is able to differentiate between different request content types, like XML and JSON), and am also willing to contribute. It is a very elegant concept, not so many lines of code, and it makes life a lot easier :)