MultiParamConverter for Symfony2
Tagged with: [ annotation ] [ bundle ] [ multiparamconverter ] [ symfony ] [ symfony2 ]
If you know Symfony2, you probably are using (or at least, have heard of) the @paramConverter annotation from the SensioFrameworkExtraBundle. This is a really simple way to convert slugs into entities. But lots of times I find myself having multiple slugs inside my routes, and this is something the @paramConverter annotation cannot do. So that’s why I’ve created the multiParamConverter.
Introduction into the standard paramConverter
ParamConverting is a simple way to convert so-called slugs automatically into entities in your controllers. Suppose you
have a route for /article/{id}
, where inside your controller-action you must find the correct article for the id:
even though it’s only a few extra lines of code, this is a tedious and repetitive task. Luckily, the @paramConverter
can help us with this:
The param converter will hook into the kernel before the action is called, and adds an “article” argument of the
specified class. This article will be fetched from the slug {id}
and works pretty much the same way as the example
above. It throws an exception when it’s not found etc. It’s even simpler when you use typehinting, since you can
completely leave out the annotation together.
There is room for multiple converters, but currently there is only one: the doctrine converter. This will convert doctrine entities, but if you have your own ways of converting (to entities not managed by doctrine), it’s possible to add your own.
The multiParamConverter
However, the standard param converter has some drawbacks: first of all, you can only convert one slug per action. There
is no room for multiple @paramConverter
(annotations) per action. Secondly, the slug name used is ALWAYS {id}
. This
does not always is suitable for your needs, for instance, it makes much more sense to have a route named
/airport/{iatacode}
then /airport/{id}
, since the id in that case is an iata code (like /airport/AMS
, or
/airport/LPA
, not /airport/1
or /aiport/14
).
The initial idea came from a pull request that still hasn’t been merged into the bundle itself. Because we didn’t
want to implement the patch ourselves (with the hassle of updating the bundle over and over again), we created a new
bundle that implements a new converter. This is the @multiParamConverter
and besides the fact you can have multiple
slugs / entities per action, it also has some other features not available in the original @paramConverter
:
- you can customize the slug-name that will be converted.
- you can supply the actual fetch-method that will be called.
The first feature is born out of necessity, since converting multiple slugs means we cannot use {id}
for each slug.
The second feature was something we found useful. In the example of airports, we have a primary ID (a number), and a unique IATA-code. Since we want to convert through IATA code, we must tell the converter a different fetch method must be used.
Examples
Inner workings
So how does this (and the standard converter, since they are pretty similar) work?
First of all, the multiParamConverter is in many ways a direct copy of the standard ParamConverter. The original ParamConverter allows you to add extra converters next to the standard doctrineParamConverter. Unfortunately, this would not help us in our case, since we still can only one converter per action, and the whole idea is to use multiple converters per annotation.
Listeners:
There are two listeners added to the kernel.controller event: the annotation listener and the converter listener. It is important that the annotation listener is called before the converter listener, since the latter will use information created by the annotation listener. We can force this order by either define the annotation listener before the converter OR to give the annotation listener a higher priority (which is safer than being dependent on ordering in your service configuration to be honest).
The annotation listener:
The annotation listener is defined as a service and will receive the annotation_reader server as an argument.
The converter listener:
This listener is also defined as a service and will receive a converter manager as an argument. This converter manager does nothing more than collect all the paramconverters and calls these converters in the correct order for each “configuration”. We use our own converter manager, but it would very well be possible to use the converter manager from the SensioExtraBundle (but it HAS to be another instance!). We extend the SensioExtraBundle’s converter manager, but don’t change any functionality.
The most important files:
These are the most important files from the bundle, why they are there and how they work.
Service configuration (resources/service.yml):
The service.yml consists of parameters and services. The paramaters are simply key/value mappings to classes. The
services contain two listener definitions: noxlogic_common_bundle.converter.listener
and the
noxlogic_common_bundle.converter.listener
. You see that they are both tagged as a kernel.event_listener
, and listen to
the kernel.controller event
. The annotation listener has a higher priority to make sure it will be executed before the
converter listener.
The 3rd service is our param converter manager, which collects all our param converters.
The last service is our actual param converter. Since it’s also a doctrine converter, we give doctrine as an argument,
and tag this converter as a request.multi_param_converter. This allows us to inject the converter into the param
converter manager (see the AddMultipleParamConverterPass). If you want to add your own converter, you can easily tag
your converter as request.multi_param_converter
and it will automatically be added.
NoxLogicMultiParamBundle.php
In the build()
method we add an extra compiler pass. This is needed to inform the dependency injection system that we
want to collect and add param_converters to our manager. With this we can add other param converters from other bundles
if needed later on.
DependencyInjection\Compiler\AddMultiParamConverterPass.php
Adds all services tagged to our converter manager. First we check if we actually defined a param converter manager. If
so, we collect all services tagged with request.multi_param_converter
, and add them to the manager (with the correct
priority).
Configuration\MultiParamConverter.php
This is the actual annotation class. Important to notice is the @annotation
. This makes that the class (and the
classname) is an annotation. The class itself isn’t really much more than a data value object where the annotation
automatically calls the setters of the class. So when you have a @foo("baz", "bar" => "qux")
annotation, it will
instantiate a new Foo
class and it will call setName("baz")
and setBar("qux")
. The only thing that really matters
is the getAliasName()
, which we use later on to distinguish between our annotations/params and others (like the
SensioExtraBundle parameters).
EventListener\ControllerListener.php
This is the annotation reader. The constructor receives a reader through DI. The onKernelController
method, the method
we defined in services.yml
, is the called method on a kernel.controller
event.
Here we fetch the object and method from the event (basically the controller and action). It will iterate over all
annotations defined in the docblock of that action and checks if an instance of MultiParamConverter has been found
(there can be different annotations like @route
, @template
etc).
This is where the getAliasName()
comes into action: every (annotation)configuration will be added to an array called
_multiparam_converters
inside in the request object. This way we can have multiple converters at one action. All other
annotation configurations will be ignored and (hopefully) picked up by other listeners.
EventListener\MultipleParamConverterListener.php
This listener prepares the conversion of our parameters. From this point on we assume that the _multiparam_converters
array in the request is filled with the actual annotation classes (this is why the annotation listener must run before
this listener).
First of all, there is a check to see if there are _multiparam_converter
s available. If so, these configuration will be
stored separately.
Next up, we will do a reflection on the controller/action we are calling. This way we can find all the arguments (and typehints) that we need to convert. We loop over all these arguments:
If the argument hasn’t got a class, we continue with the next argument. If we have an item with the same name inside our request object, we assume that something already set this variable, and we don’t need to convert it (this is why your slug name must be different than the argument name! We could however, check the type to see if conversion actually took place, but we don’t at the moment).
If the attribute hasn’t been set, we must perform a conversion. If the attribute doesn’t exist in your $configurations array, it means that the parameter wasn’t defined through an annotation. At this point, we will create our own default configuration and assume all default values.
Next we check if the argument is optional or not. Optional arguments are arguments which have a default value (like:
function foo($bar = 1)
);
Now that we have set all configurations, we ask the manager to do the actual conversions through its apply()
method.
Request\ParamConverter\MultiParamConverterManager.php
This is an empty class that extends the SensioExtraBundle paramConverterManager. This is needed because both the SensioExtraBundle and ours need a different instance of a ParamConverter Manager. This also means that the actual apply() method we call at the end of the converter listener is exactly the same as the SensioExtraBundle. If you take a look at that method, you see it isn’t really complex what it will do.
Request\ParamConverter\MultiParamConverter.php
This is the actual converter that is called through the Manger. It is automatically added to the manager because we defined this class as a service in service.yml and tagged it as multiparam_converter service. The compilerPass we appended to this bundle automatically added it to the manager because of this.
The getOptions()
method makes sure that we always have an entity_manager defined.If it isn’t present, it will assume the
“default” manager. (not the default manager, but a manager named default, which normally is the default manager, but not
always, yeah, it’s confusing :)).
The supports()
method checks if we are actually able to perform a conversion. It does this by checking if the class we
need to convert to is actually present as metadata inside the entity-manager.
The apply()
method does the actual conversion by calling the find()
method, or if that fails, the findOnyBy()
method. After the conversion is will save the new entity inside the request object.
We have some modifications inside the find()
method, compared to the SensioExtraBundle. Instead of looking for just an
attribute inside the request named “id”, there is a list of names we need to check: the name given in the annoation
(“id” option), name of the slug + “_id”, or as a fallback the name “id”.
Second, instead of calling the find()
method on the entity’s repository, we actually allow a user to give a custom
method. If none are given, we assume the “find” method.
The findByOne()
method isn’t adapted to this, and will not use our custom “method”. It is possible we need to fix this
in order to make it work correctly.
That’s it.. It looks like a lot of things are happening, and indeed it does. But when you take some time and read how all files are connected, it becomes clear how it all fit together.
Download
You can find the bundle at: https://github.com/jaytaph/MultiParamBundle or it’s available through packagist.org/composer as well! Special thanks to CruiseTravel, for whom this bundle was originally created for and allowed to open source the code!