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...