Can Query Parameters Use Annotations?


In this video we are going to take a more in-depth look at getting data from our the query string in Symfony.

If you are unsure, by getting data from the query string I mean suppose our user sends in a request to:

http://mysite.com/some/route?a=b&c=d

Where ?a=b&c=d are our query parameters.

The problem in accepting data in this format is that we cannot protect ourselves with routing annotations, as we saw in the previous video.

That said, if you were to add in some defaults, they would confusingly still work. Sort of. For example:

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo/{userId}",
     *      name="demo",
     *      defaults={
     *          "userId": "987"
     *      }
     * )
     */
    public function demoAction($userId)
    {
        dump($userId);

        return $this->render('::base.html.twig');
    }

As we know from the last video, if we miss off the {userId} portion of our URL, the output of dump($userId); would be 987.

However, if we change this routing annotation as follows:

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo",
     *      name="demo",
     *      defaults={
     *          "userId": "987"
     *      }
     * )
     */
    public function demoAction($userId)
    {
        dump($userId);

        return $this->render('::base.html.twig');
    }

And then we send in a request to:

http://mysite.com/demo?userId=123

Then the contents of $userId would still be 987.

Why? Because defaults have no impact on the query string.

Instead, we must inject the $request object, and use the $request->query public property, which thankfully contains a variety of methods to assist us in validating - to some extent - that the input is as it should be.

Injecting The Request

Symfony is all about getting from Request to Reponse.

It therefore stands to reason that Symfony should make working with the incoming Request as painless as possible.

One helpful way in which Symfony improves the development experience when working with each incoming request is by converting the query parameters into a PHP array for us.

We can then gain access to this array of data via $request->query->all():

use Symfony\Component\HttpFoundation\Request;

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo",
     *      name="demo"
     * )
     */
    public function demoAction(Request $request)
    {
        dump($request->query->all());

        return $this->render('::base.html.twig');
    }

Should we now send in a request:

http://mysite.com/demo?a=b&c=d

Then we should expect the output of our dump command to be an array containing ["a"=>"b", "c"=>"d"];.

The reasoning for the somewhat unusual syntax of $request->query, rather than $request->query() is that query is a public property of the Request class.

This query property is an instance of a ParameterBag class.

Initially I found this confusing. Is this related to parameters.yml? The answer is simple - no, it isn't.

A ParameterBag is a container of keys and their values.

It gives a nicer interface for working with our data than simply working with our data as a raw array. For example, we can get the contents of a given key by using get:

use Symfony\Component\HttpFoundation\Request;

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo",
     *      name="demo"
     * )
     */
    public function demoAction(Request $request)
    {
        dump($request->query->get('c'));

        return $this->render('::base.html.twig');
    }

And with a request as before:

http://mysite.com/demo?a=b&c=d

We should expect to see the dump output of d, which is the value assigned to the key of c.

If we attempted to get a value that has not been provided, rather than blow up, we simply get a null:

GET http://mysite.com/demo?a=b&c=d

    public function demoAction(Request $request)
    {
        dump($request->query->get('x')); // null

There are other convenience methods such as count, and keys:

GET http://mysite.com/demo?a=b&c=d

    public function demoAction(Request $request)
    {
        dump($request->query->count()); // 2, as in two keys, a and c
        dump($request->query->keys()); // an array, [0=>'a', 1=>'c']

More commonly I find myself checking if a parameter even exists:

GET http://mysite.com/demo?a=b&c=d

    public function demoAction(Request $request)
    {
        dump($request->query->has('a')); // true
        dump($request->query->has('y')); // false

Which you can then use as the predicate (computer science term for a function that returns either true or false) for an if statement:

GET http://mysite.com/demo?a=b&c=d

    public function demoAction(Request $request)
    {
        if ($request->query->has('a')) {
            // do some logic
        }

We can also change the contents of the $request->query parameter bag. Now, be careful here as this will mutate the original array, which is often how unexpected bugs slip in.

We can add, and remove:

GET http://mysite.com/demo?a=b&c=d

    public function demoAction(Request $request)
    {
        dump($request->query->keys()); // an array, [0=>'a', 1=>'c']

        $request->query->add(["e"=>"f"]);
        dump($request->query->all()); // ["a"=>"b", "c"=>"d", "e"=>"f"]

        $request->query->remove("a");
        dump($request->query->all()); // ["c"=>"d", "e"=>"f"]

And then replace is the most dangerous:

GET http://mysite.com/demo?a=b&c=d

    public function demoAction(Request $request)
    {
        dump($request->query->keys()); // an array, [0=>'a', 1=>'c']

        $request->query->replace(["new"=>"data"]);
        dump($request->query->all()); // ["new"=>"data"]

Of course, all of these are things that can be checked for with a decent set of tests.

First Line Of Defence

The real reason for all this is to call attention to the additonal methods provided by the parameter bag.

These would be getAlnum, getDigits, getBoolean, and so on.

The problem is that we cannot stop our users from meddling with the query string.

Rather than worry too much about this, we can apply these basic filtering methods to the given input, and work with the resulting output accordingly.

However, we do need to be aware of the confusing defaults. Let's take a further look:

GET http://mysite.com/demo?a=123something%20here456

    public function demoAction(Request $request)
    {
        dump($request->query->get('a')); // "123something here456"
        dump($request->query->getAlpha('a')); // "somethinghere" - numbers, and space stripped
        dump($request->query->getAlnum('a')); // "123somethinghere456" - space stripped
        dump($request->query->getDigits('a')); // "123456" - letters, and space stripped
        dump($request->query->getInt('a')); // 123 - output of casting to (int)
        dump($request->query->getBoolean('a')); // false - only 1, or "true" are true

Each of these methods can be examined further by simply looking at the code inside ParameterBag. An example may be getAlpha:

// /vendor/symfony/symfony/src/Symfony/Component/HttpFoundation/ParameterBag.php

    /**
     * Returns the alphabetic characters of the parameter value.
     *
     * @param string $key     The parameter key
     * @param string $default The default value if the parameter key does not exist
     *
     * @return string The filtered value
     */
    public function getAlpha($key, $default = '')
    {
        return preg_replace('/[^[:alpha:]]/', '', $this->get($key, $default));
    }

An eye-opener for me was the use of the POSIX regular expressions which certainly make it more immediately obvious what the regex should match. You can find out more about these here.

(In)sane Defaults

Another way I have caught myself out in the past is by making assumptions about how the code will work, rather than either a) reading the code, or b) reading the docs.

Let me save you some time / embarrassment here.

For each of these:

  • get
  • getAlpha
  • getAlnum
  • getDigits
  • getInt
  • getBoolean

You can provide a default as the second parameter.

An example:

GET http://mysite.com/demo?a=some%20text

    public function demoAction(Request $request)
    {
        dump($request->query->getAlpha('a', "some default")); // output: sometext

My mistake was in the belief that if the filter didn't match, then the default would be used.

In hindsight, I don't know how I expected the default of get to work, given that it returns the unfiltered value, but hey, that just further highlights my mistake.

The default value will only be used if the parameter key itself is missing from the URL:

GET http://mysite.com/demo?a=some%20text

    public function demoAction(Request $request)
    {
        dump($request->query->getAlpha('x', "some default")); // output: "some default"

Again, another thing that tests will catch - and you do have tests, right?

Custom Filters

Up to now we have used Symfony's built in methods direct on the ParameterBag.

If we look at the method for getBoolean however, we can see that more advanced filters can be used:

// /vendor/symfony/symfony/src/Symfony/Component/HttpFoundation/ParameterBag.php

    /**
     * Returns the parameter value converted to boolean.
     *
     * @param string $key     The parameter key
     * @param mixed  $default The default value if the parameter key does not exist
     *
     * @return bool The filtered value
     */
    public function getBoolean($key, $default = false)
    {
        return $this->filter($key, $default, FILTER_VALIDATE_BOOLEAN);
    }

I will leave it up to you to look further into the code for filter if you are so inclined, but I will point you towards the variety of filter constants that PHP has built in. One hint here - if you can do it with an assertion, you can generally find a filter that will get you near, if not the same result.

A Better Solution

Having covered all this, it is well worth pointing out that FOSRESTBundle has a better solution to this problem by way of its ParamFetcher annotations.

Essentially this will give you the same functionality as we saw with routing placeholders, only they will work in query parameters.

This sounds awesome, right? So, what's the catch?

Well, it means having FOSRESTBundle as a dependency of your project. That's a lot of 'stuff' simply for an annotation.

Personally I don't mind this, but I know plenty that do. Ultimately this becomes a judgement call for your own project. Easy life, vs roll your own...

Episodes

# Title Duration
1 How to Get The Request / Query Parameters in Symfony? 07:06
2 Straightforward Routing Requirements 04:36
3 Can Query Parameters Use Annotations? 06:42