MultiParamConverter for Symfony2

Warning: This blogpost has been automatically converted from WordPress to Jekyll, and hasn't been fully checked yet. It might be possible that it misses some code snippets or that the formatting is not yet complete. As soon as this blogpost has been checked, this banner will automatically be removed.
Warning: This blogpost has been posted over two years ago. That is a long time in development-world! The story here may not be relevant, complete or secure. Code might not be complete or obsoleted, and even my current vision might have (completely) changed on the subject. So please do read further, but use it with caution.
Posted on 04 Aug 2012
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:

/**
 * @route("/article/{id}")
 */
function showAction($id) {
  $em = $this->getDoctrine()->getEntityManager();
  $article = $em->getRepository('AcmeBundle:Article')->find($id);
  if (! $article) {
    throw new HttpException(404, "entity not found");
  }

  ...
}

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:

/**
 * @route("/article/{id}")
 * @paramConverter("article", class="AcmeBundle:Article")
 */
function showAction($article) {
  // Work directly with $article
  ...
}

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.

/**
 * @route("/article/{id}")
 */
function showAction(Article $article) {
  // Work directly with $article
  ...
}

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

/**
 * @route("/country/{iso}/airport/{iata}")
 * @multiParamConverter("country", class="acmeBundle:Country", options={"id" = "iso"})
 * @multiParamConverter("airport", class="acmeBundle:Airport", options={"id" = "iata"})
 */
function showAction(Country $country, Airport $airport) {
  ...
}

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_converters 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!