What if we define validations rules in the routing configuration ๐Ÿ˜ฑ

Smaine Milianni on 2021-11-20

๐Ÿ’ก In this article, Iโ€™ll show how you can add validation on a Request content. (โ„น๏ธ ๏ธItโ€™s more about a way to learn more about Request and event listener than a best practice)

A common use case when you create a route is to validate the Request content, suppose you expose an API that creates a User, you want to be sure that the name is not null, the email is valid and other stuff like this... There are many possibilities to do this, Iโ€™ll show another one ๐Ÿ˜‰.

๐Ÿ‰ The idea is simple, the Symfony Routing allows us to specify extra information that will be then accessible from the famous Request object. We will use this possibility to pass our validation rules, then we will create a Listener that listens to the event Kernel.request and inside this listener, we will validate the request content with our validation rules ๐Ÿ”ฅ

1- Create the Controller โš’๏ธ

Suppose we expose an API that creates a user and we want to validate that the name is not blank and the email is a valid email.

We have to create a Route and inside the configuration, we add a custom array _validator inside the defaults option. We match each property with a Symfony constraint, in my example, I just have one constraint per property and I donโ€™t add validator options like messageโ€ฆ Feel free to change this to fit your needs. Then in the action if we get errors from the request attributes and if we have errors we format them to return a readable response.

CreateUserController.php

<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class CreateAUserController
{
    /**
     * @Route("/users",
     *       name="create",
     *       methods="POST",
     *       defaults={
     *          "_validator": { // the " _validator" name is totally custom, choose what you want.
     *              "name":"NotBlank",
     *              "email":"Email"
     *       }
     * })
     */
    public function create(Request $request): JsonResponse
    {
        // retrieve errors that are set in the our listener
        $errors = $request->attributes->get('_errors');
        
         // return 400 in case of errors
        if ([] !== $errors) {
            return new JsonResponse($this->formatError($errors), 400);
        }
        
        // boring stuff to create the user
        
        return new JsonResponse($user, 201);
    }
    
    // Helper to properly return the errors
    private function formatError(ConstraintViolationList $constraintViolationList): array
    {
        $format = [];

        foreach ($constraintViolationList as $violation) {
            $format[] = [
                'property' => $violation->getPropertyPath(),
                'message' => $violation->getMessage()
            ];
        }

        return $format;
    }
}

๐ŸŽ (Bonus) I need an endpoint to list my users and I want to validate that the limit parameter is Positive.

Same stuff, I create my controller, add an extra parameter and match the property to validate with a constraint.

ListUserController.php

<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class UserAction
{  
     /**
     * @Route("/users",
     *       name="get",
     *       methods="GET",
     *       defaults={
     *          "_validator": {
     *              "limit":"Positive"
     *       }
     * })
     */
    public function search(Request $request): Response
    {
        $errors = $request->attributes->get('_errors');

        if ([] !== $errors) {
            return new JsonResponse($this->formatError($errors), 400);
        }
        
        // boring stuff, fetch users...
        return new JsonResponse($users, 201);
    }
}

2- Create the Listener ๐Ÿ—œ๏ธ

Our job is to listen to the kernel.request event, it allows us to get the Request content and the extra attributes before executing the logic inside a controller.

In the listener, we will create a collection of constraints and we will validate the submitted payload or the query string, it depends on the HTTP Method. Then we set the errors in the Request attributes to get them from the controller. ๐Ÿ‘Œ

RequestValidatorListener.php

<?php

namespace App\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\Constraints;
use Symfony\Component\Validator\Constraint;

final class ValidatorRequestListener implements EventSubscriberInterface
{
    private ValidatorInterface $validator;

    public function __construct(ValidatorInterface $validator)
    {
        $this->validator = $validator;
    }

    public function onKernelRequest(RequestEvent $event = null)
    {
        /** @var Request $request */
        $request = $event->getRequest();
        
        // we fetch the validator
        $validator = $request->get('_validator');

        // Early return if we don't have defined a Validator or
        // the method is not a POST,PUT or GET.
        if (null === $validator || !in_array($request->getMethod(), ['POST', 'PUT', 'GET'])) {
            return;
        }

        // We create a collection of constraint(s) with the constraints given in the defaults Route option.
        $collection = [];
        foreach ($validator as $propertyToValidate => $constraintName) {
            // Hack to dynamically create the constraint
            // โš ๏ธ My case is simple, I just have 1 constraint per property
            // if you have an array of constraints for a property, you need to iterate on them
            $fqcn = "Symfony\Component\Validator\Constraints\\${constraintName}";
            $constraint = new $fqcn;

            if (!$constraint instanceof Constraint) {
                throw new \LogicException("Only Constraint are allowed to validate value");
            }

            $collection[$propertyToValidate] = new $constraint;
        }

        // If method is POST or PUT, we should get the payload from the request content
        // otherwise we get it from query string
        if (in_array($request->getMethod(), ['POST', 'PUT'])) {
            $payload = \json_decode($request->getContent(), true);
        } else {
            // Retrieve the input from the query string.
            $payload = $request->query->all();
        }

        // We remove properties that should not have to be validated
        $input = array_intersect_key($payload, $collection);
        $errors = $this->validator->validate($input, new Constraints\Collection($collection));

        // We set errors in a key "_errors" that can be retrieve from
        // the request object with $request->attributes->get('_errors');
        $request->attributes->set('_errors', $errors);
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => [['onKernelRequest', 31]],
        ];
    }

}

3- Try it ๐Ÿ˜š

I make a POST request with a name = nulland a wrong email. I add a dump to output the errors:

$errors = $request->attributes->get('_errors');
dd($errors);

๐ŸŽ‰As you can see, the validator did the job and validate the payload, now letโ€™s try with a GET method. I make a GET with limit=-42

Same thing โœจ, the validator did the job too.

4- Automation testing ๐Ÿค–

But we are professional developers, so we have to write tests.

ApiUserTest.php

<?php
namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class UserActionTest extends WebTestCase
{
    public function testWrongPayload(): void
    {
        $client = static::createClient();
        // We create a wrong payload
        $payload = [
            "email" => "FR_fr",
            "name" => null
        ];
       
        // send the request
        $client->request('POST', '/users', [], [], [], json_encode($payload));
        
        // decode the response as an array
        $response = json_decode($client->getResponse()->getContent(), true);

        $this->assertResponseStatusCodeSame(400);

        $expected = [
            [
                "property" => "[name]",
                "message" => "This value should not be blank."
            ],
            [
                "property" => "[email]",
                "message" => "This value is not a valid email address."
            ]
        ];

        self::assertEquals($expected, $response);
    }

    public function testWrongLimitCriteria(): void
    {
        $client = static::createClient();
        $client->request('GET', '/users?limit=-42');
        $response = json_decode($client->getResponse()->getContent(), true);

        $expected = [
            [
                "property" => "[limit]",
                'message' => 'This value should be positive.'
            ]
        ];
        self::assertEquals($expected, $response);
    }
}

And itโ€™s ๐Ÿ’š

Thatโ€™s all, thanks for reading ๐Ÿ“š, I hope you liked it, donโ€™t forget to clap ๐Ÿ‘and share the article.

Follow me on Twitter or reach me on LinkedIn ๐Ÿ‘‹

You can get the source code ๐Ÿ‘‰ here