Validando requests no Symfony Framework
Tags: php, symfony, webdev, api
Published: 2023-07-05 21:45:47

Hoje o [Symfony](https://symfony.com/) é 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](https://symfony.com/blog/new-in-symfony-6-3-mapping-request-data-to-typed-objects), 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](https://www.php.net/releases/8.0/en.php#attributes) e [atributos readonly](https://www.php.net/releases/8.1/en.php#readonly_properties) e criar validações para Requests no Symfony.

Para isto, iremos usar o componente [Symfony Validation](https://symfony.com/doc/current/validation.html).

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

```bash
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
}'
```

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

![Request error exception](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yx5st7uegukmyygnhyyv.png)

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](https://twitter.com/faizanakram99)** e **[mdeboer](https://github.com/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.


![Symfony exception response json](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8a806ow3vz35i2gool66.png)

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](https://twitter.com/faizanakram99)** e **[mdeboer](https://github.com/mdeboer)** pela contribuição.

## Classe de Request abstrata

Uma das grandes vantagens do Symfony é o grande e extenso suporte a DIP "[Dependency inversion principle](https://en.wikipedia.org/wiki/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
<?php declare(strict_types=1);

namespace App\Request;

use Fig\Http\Message\StatusCodeInterface;
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](https://www.php-fig.org/psr/psr-2/) e [PSR-12](https://www.php-fig.org/psr/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](https://github.com/jawira/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
<?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
<?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.

```bash
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
}'
```

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

```bash
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
}'
```

```json
< 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 :)


![Exception readonly attribute not initialized](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/opwr9isow8wx81gm7lqv.png)

Por fim, deixo meus agradecimentos ao grande amigo [Vinícius Dias](https://dias.dev) que me ajudou na revisão deste artigo.

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