Validando requests no Symfony Framework

Hoje o Symfony é um dos mais maduros e robustos frameworks no mercado e por conta disto, é usado em vários projetos, incluíndo a criação de APIs. Recentemente o Symfony incluiu várias features legais, como a funcionalidade de mapear os dados da Request para um Objeto, que surgiu na versão 6.3.

Com isto iremos aproveitar alguns dos melhores recursos das últimas versões do PHP, que é o suporte a attributes e atributos readonly e criar validações para Requests no Symfony.

Para isto, iremos usar o componente Symfony Validation.

Estou sem paciência, mostre me o código!

Okay okay! Caso você não esteja com paciência para ler este artigo, eu tenho um projeto de teste com a implementação deste artigo no link abaixo.

https://github.com/joubertredrat/symfony-request-validation

Exemplo básico

Seguindo a própria documentação, basta criarmos uma classe que iremos usar para mapear os valores da request, como no exemplo abaixo.

<?php declare(strict_types=1);

namespace App\Dto;

use App\Validator\CreditCard;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Positive;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Type;

class CreateTransactionDto
{
    public function __construct(
        #[NotBlank(message: 'I dont like this field empty')]
        #[Type('string')]
        public readonly string $firstName,

        #[NotBlank(message: 'I dont like this field empty')]
        #[Type('string')]
        public readonly string $lastName,

        #[NotBlank()]
        #[Type('string')]
        #[CreditCard()]
        public readonly string $cardNumber,

        #[NotBlank()]
        #[Positive()]
        public readonly int $amount,

        #[NotBlank()]
        #[Type('int')]
        #[Range(
            min: 1,
            max: 12,
            notInRangeMessage: 'Expected to be between {{ min }} and {{ max }}, got {{ value }}',
        )]
        public readonly int $installments,

        #[Type('string')]
        public ?string $description = null,
    ) {
    }
}

Com isto, basta usar a classe como dependência no método do controller com a annotation #[MapRequestPayload] e pronto, os valores serão automaticamente mapeados para o objeto, como no exemplo abaixo.

<?php declare(strict_types=1);

namespace App\Controller;

use App\Dto\CreateTransactionDto;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Annotation\Route;

class TransactionController extends AbstractController
{
    #[Route('/api/v1/transactions', name: 'app_api_create_transaction_v1', methods: ['POST'])]
    public function v1Create(#[MapRequestPayload] CreateTransactionDto $createTransaction): JsonResponse
    {
        return $this->json([
            'response' => 'ok',
            'datetime' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
            'firstName' => $createTransaction->firstName,
            'lastName' => $createTransaction->lastName,
            'amount' => $createTransaction->amount,
            'installments' => $createTransaction->installments,
            'description' => $createTransaction->description,
        ]);
    }
}

Com isto, basta fazermos uma requisição e vermos o resultado.

curl --request POST \
  --url http://127.0.0.1:8001/api/v1/transactions \
  --header 'Content-Type: application/json' \
  --data '{
  "firstName": "Joubert",
  "lastName": "RedRat",
  "cardNumber": "4130731304267489",
  "amount": 35011757,
  "installments": 2
}'
< HTTP/1.1 200 OK
< Content-Type: application/json

{
  "response": "ok",
  "datetime": "2023-07-04 19:36:37",
  "firstName": "Joubert",
  "lastName": "RedRat",
  "cardNumber": "4130731304267489",
  "amount": 35011757,
  "installments": 2,
  "description": null
}

No exemplo acima, caso os valores não sejam corretamente preenchidos de acordo com as regras definidas, será disparado uma exceção e recebemos uma resposta com os erros encontrados.

O porém é que esta exceção é padrão ValidationFailedException e como estamos construindo uma API, é necessária uma resposta em formato de json.

Com isto em mente, podemos tentar uma abordagem diferente, que será explicado a seguir.

UPDATE Agosto/2023: Depois de ter publicado a versão em inglês deste post e compartilhado no slack do Symfony, Faizan e mdeboer me falaram que é possível ter a resposta em JSON pois o Symfony tem um normalizer para estes casos.

Para obter uma resposta em JSON, você deve adicionar o header Accept: application/json, com isto a resposta também será em JSON, como no exemplo abaixo.

Como é possível ver, de fato, a resposta é em JSON, porém, no padrão do Symfony baseado no RFC 7807. A seguir, iremos fazer uma classe para podermos formatar a resposta de JSON no formato que desejamos.

Obrigado Faizan e mdeboer pela contribuição.

Classe de Request abstrata

Uma das grandes vantagens do Symfony é o grande e extenso suporte a DIP "Dependency inversion principle" por meio do seu poderoso container de injeção de dependências com suporte a autowire.

Com isto, iremos criar a nossa classe abstrata, que vai conter todo o código responsável por fazer o parse da request e validação, como no exemplo abaixo.

<?php declare(strict_types=1);

namespace App\Request;

use Jawira\CaseConverter\Convert;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Validator\ValidatorInterface;

abstract class AbstractJsonRequest
{
    public function __construct(
        protected ValidatorInterface $validator,
        protected RequestStack $requestStack,
    ) {
        $this->populate();
        $this->validate();
    }

    public function getRequest(): Request
    {
        return $this->requestStack->getCurrentRequest();
    }

    protected function populate(): void
    {
        $request = $this->getRequest();
        $reflection = new \ReflectionClass($this);

        foreach ($request->toArray() as $property => $value) {
            $attribute = self::camelCase($property);
            if (property_exists($this, $attribute)) {
                $reflectionProperty = $reflection->getProperty($attribute);
                $reflectionProperty->setValue($this, $value);
            }
        }
    }

    protected function validate(): void
    {
        $violations = $this->validator->validate($this);
        if (count($violations) < 1) {
            return;
        }

        $errors = [];

        /** @var \Symfony\Component\Validator\ConstraintViolation */
        foreach ($violations as $violation) {
            $attribute = self::snakeCase($violation->getPropertyPath());
            $errors[] = [
                'property' => $attribute,
                'value' => $violation->getInvalidValue(),
                'message' => $violation->getMessage(),
            ];
        }

        $response = new JsonResponse(['errors' => $messages], 400);
        $response->send();
        exit;
    }

    private static function camelCase(string $attribute): string
    {
        return (new Convert($attribute))->toCamel();
    }

    private static function snakeCase(string $attribute): string
    {
        return (new Convert($attribute))->toSnake();
    }
}

Na classe acima podemos ver que recebe o ValidatorInterface e o RequestStack como dependências e no construtor é realizado o preenchimento e validação dos atributos.

Também é possível ver a conversão entre os padrões snake_case e camelCase nos atributos e erros, isto ocorre porque existe uma convenção em que os campos de um JSON devem ser snake_case, enquanto a PSR-2 e PSR-12 sugere o uso camelCase para nomes de atributos nas classes, então é feito esta conversão. Para isto foi utilizado a biblioteca Case converter.

Porém, vale lembrar que isto não é uma regra absoluta, se você quiser usar qualquer padrão diferente do snake_case no JSON, você pode.

Classe de Request com os atributos de validação

Com a classe abstrata responsável por toda a validação, agora iremos criar as classes de validação, como no exemplo abaixo.

<?php declare(strict_types=1);

namespace App\Request;

use App\Validator\CreditCard;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Positive;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Type;

class CreateTransactionRequest extends AbstractJsonRequest
{
    #[NotBlank(message: 'I dont like this field empty')]
    #[Type('string')]
    public readonly string $firstName;

    #[NotBlank(message: 'I dont like this field empty')]
    #[Type('string')]
    public readonly string $lastName;

    #[NotBlank()]
    #[Type('string')]
    #[CreditCard()]
    public readonly string $cardNumber;

    #[NotBlank()]
    #[Positive()]
    public readonly int $amount;

    #[NotBlank()]
    #[Type('int')]
    #[Range(
        min: 1,
        max: 12,
        notInRangeMessage: 'Expected to be between {{ min }} and {{ max }}, got {{ value }}',
    )]
    public readonly int $installments;

    #[Type('string')]
    public ?string $description = null;
}

A grande vantagem da classe acima é que todos os atributos obrigatórios da request estão com o status readonly, sendo possível garantir a imutabilidade dos dados. Outro ponto interessante é poder usar os attributes do Symfony Validation para fazer as validações necessárias ou até mesmo criar validações customizadas.

Usando a classe de request na Rota

Com a classe de request pronta, agora é só usar ela como dependência na rota que você deseja fazer a validação, valendo lembrar que diferente do exemplo anterior, aqui não será necessaria a annotation #[MapRequestPayload], como no exemplo abaixo.

<?php declare(strict_types=1);

namespace App\Controller;

use App\Request\CreateTransactionRequest;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class TransactionController extends AbstractController
{
    #[Route('/api/v2/transactions', name: 'app_api_create_transaction_v2', methods: ['POST'])]
    public function v2Create(CreateTransactionRequest $request): JsonResponse
    {
        return $this->json([
            'response' => 'ok',
            'datetime' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
            'first_name' => $request->firstName,
            'last_name' => $request->lastName,
            'amount' => $request->amount,
            'installments' => $request->installments,
            'description' => $request->description,
            'headers' => [
                'Content-Type' => $request
                    ->getRequest()
                    ->headers
                    ->get('Content-Type')
                ,
            ],
        ]);
    }
}

No controller acima é possível ver que não usamos o tradicional Request do HttpFoundation e sim a nossa classe CreateTransactionRequest como dependência e é ali que ocorre a mágica, pois todas as dependências necessárias serão injetadas e a validação será realizada.

Vantagens desta abordagem

Em relação ao exemplo básico, esta abordagem tem duas grandes vantagens.

  • É possível customizar a estrutura do json e o status code de resposta como desejar.

  • É possível ter acesso a classe de Request do Smyfony que foi injetada como dependência, com isto é possível acessar qualquer informação da request, como os headers por exemplo. No exemplo básico isto não é possível, a menos que você também coloque a classe Request como dependência na rota, o que seria estranho, pois teria duas fontes de dados distintas da mesma requisição.

Hora do teste!

Com a nossa implementação pronta, vamos para os testes.

A request do exemplo está com erros propositais para nós podemos ver as respostas da validação.

curl --request POST \
  --url http://127.0.0.1:8001/api/v2/transactions \
  --header 'Content-Type: application/json' \
  --data '{
  "last_name": "RedRat",
  "card_number": "1130731304267489",
  "amount": -4,
  "installments": 16
}'
< HTTP/1.1 400 Bad Request
< Content-Type: application/json

{
  "errors": [
    {
      "property": "first_name",
      "value": null,
      "message": "I dont like this field empty."
    },
    {
      "property": "card_number",
      "value": "1130731304267489",
      "message": "Expected valid credit card number."
    },
    {
      "property": "amount",
      "value": -4,
      "message": "This value should be positive."
    },
    {
      "property": "installments",
      "value": 16,
      "message": "Expected to be between 1 and 12, got 16"
    }
  ]
}

Como podemos ver, a validação ocorreu com sucesso e os campos não preenchidos, ou com valores incorretos não passaram pela validação e tivemos a resposta da validação.

Agora, iremos fazer uma request válida e ver que haverá sucesso na response, pois todos os campos estarão dentro do que desejamos.

curl --request POST \
  --url http://127.0.0.1:8001/api/v2/transactions \
  --header 'Content-Type: application/json' \
  --data '{
  "first_name": "Joubert",
  "last_name": "RedRat",
  "card_number": "4130731304267489",
  "amount": 35011757,
  "installments": 2
}'
< HTTP/1.1 200 OK
< Content-Type: application/json

{
  "response": "ok",
  "datetime": "2023-07-01 16:39:48",
  "first_name": "Joubert",
  "last_name": "RedRat",
  "card_number": "4130731304267489",
  "amount": 35011757,
  "installments": 2,
  "description": null
}

Limitações

Os campos opcionais não podem ser readonly, pois caso você queira acessar a informação sem ela ter sido inicializada, o PHP irá disparar uma exceção. Então por ora eu estou usando atributos normais com valores default para estes casos.

Eu ainda estou pesquisando alguma opção como solução para poder usar readonly nos campos opcionais, como usar Reflection por exemplo, e aceito sugestões :)

Por fim, deixo meus agradecimentos ao grande amigo Vinícius Dias que me ajudou na revisão deste artigo.

Então é isto, até a próxima!