Symfony Validator with FOS REST

In the previous blog post we learned how we can use the Symfony validator in our system. We will continue from that example and take a look at how we can use this together with the FOS REST bundle.

Validation in a REST API is as important as any part of our system.  Anytime we get a POST or PUT request, we need to validate if this data is exactly what we expect. And if not we need to inform our client in a clear and clean way what is wrong.

I have worked with multiple API’s in the past. And I don’t need to tell you that using an API with unexisting or bad validation is a pain to work with. Its frustrating, and you waste a lot of time to get it working correctly.

Save the pain for your clients, and create a robust and clean API that is a joy to use. Correctly using and implementing validation doesn’t need to be time consuming or hard. It’s easy and fast once you know how.

Your clients will love you, and that is a win for you, the business and the clients.

The setup

Before we start I assume that you already have a working Symfony project with FOS Rest installed. That you installed the symfony validator. And created a ProductDTO object  found here.

You will also need to install this packages:

  • composer require mcfedr/uuid-paramconverter
  • composer require symfony/options-resolver

The uuid parameter is a package that allows us to receive Uuid’s as url parameters. In our previous validator example we created a ProductDTO with an Uuid. IYou don’t need this package if you don’t use an Uuid.

We will continue to use the ProductDTO. Remember, as discussed before, this is optional. You could also use an Entity instead. Its all up to you.

The options resolver is required for the FOS body converter to resolve the validator options.

Adding the Validator to our REST call

First we need to have an object to validate. In this case we use the ProductDTO that we created before. Using the ParamConverter the Request is converted to our DTO.

/**
 * @Rest\Post("products")
 * @ParamConverter("productDTO", converter="fos_rest.request_body")
 * @param ProductDTO $productDTO
 * @return View
 * @throws \Exception
 */
public function postProduct(ProductDTO $productDTO): View
{

    // Save $productDTO
    
    return View::create($productDTO, Response::HTTP_CREATED);
}

And in config/fos_rest.yaml we have the following setup:

fos_rest:
    view:
        view_response_listener:  true
    exception:
        exception_controller: 'fos_rest.exception.controller:showAction'
    format_listener:
        rules:
            - { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json ] }
            - { path: ^/, priorities: [ 'html', '*/*'], fallback_format: ~, prefer_extension: true }
    body_converter:
            enabled: true

Now to enable Validator support in the body converter we need to a enable the validator and configure the validation errors parameter like this:

body_converter:
        enabled: true
        validate: true
        validation_errors_argument: validationErrors

We can then inject the ConstraintViolationListInterface to our controller action as $validationErrors. This will then try to validate the request object as if you called $validator->validate manually with $productDTO.

We can then count the results of $validationErrors. If it has value in it. Then we have some violations. We should then return the errors back to our client with a 400 status code. The request was bad, and we tell the clients why.

/**
 * @Rest\Post("products")
 * @ParamConverter("productDTO", converter="fos_rest.request_body")
 * @param ProductDTO $productDTO
 * @param ConstraintViolationListInterface $validationErrors
 * @return View
 * @throws \Exception
 */
public function postProduct(ProductDTO $productDTO, ConstraintViolationListInterface $validationErrors): View
{
    if (\count($validationErrors) > 0) {
        return View::create($validationErrors, Response::HTTP_BAD_REQUEST);
    }

    // Save $productDTO
    
    return View::create($productDTO, Response::HTTP_CREATED);
}

But now we still have one problem. The $validationErrors array will return this:

[
    {
        "messageTemplate": "This value should be greater than {{ compared_value }}.",
        "parameters": {
            "{{ value }}": "-1",
            "{{ compared_value }}": "0",
            "{{ compared_value_type }}": "integer"
        },
        "plural": null,
        "message": "This value should be greater than 0.",
        "root": {
            "id": "83da8773-8145-4521-840d-366a0604f9de",
            "name": "Product3",
            "price": -1
        },
        "propertyPath": "price",
        "invalidValue": -1,
        "constraint": {
            "defaultOption": "value",
            "requiredOptions": [],
            "targets": "property",
            "message": "This value should be greater than {{ compared_value }}.",
            "value": 0,
            "propertyPath": null,
            "payload": null
        },
        "cause": null,
        "code": "778b7ae0-84d3-481a-9dec-35fdb64b1d78"
    }
]

This is not that practical for a client of our API. Ok, it contains all the information the client would need, but more. Its to much and not informative enough. Our validation errors should be clean and provide just enough information.

Constraint Validator Normalizer

We can show prettier errors by using another feature of Symfony. If we create a new Normalizer then the auto wiring of Symfony will wire it and use if for serialization. This normalizer follows the RFC 7807 specification to generate a list of errors.

<?php

namespace App\Infrastructure\Normalizer;


use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;

/**
 * Class ConstraintViolationListNormalizer
 * @package App\Infrastructure\Normalizer
 */
final class ConstraintViolationListNormalizer implements NormalizerInterface
{

    /**
     * @param object $object
     * @param null $format
     * @param array $context
     * @return array
     */
    public function normalize($object, $format = null, array $context = array()): array
    {
        [$messages, $violations] = $this->getMessagesAndViolations($object);

        return [
            'title' => $context['title'] ?? 'An error occurred',
            'detail' => $messages ? implode("\n", $messages) : '',
            'violations' => $violations,
        ];
    }

    /**
     * @param ConstraintViolationListInterface $constraintViolationList
     * @return array
     */
    private function getMessagesAndViolations(ConstraintViolationListInterface $constraintViolationList): array
    {
        $violations = $messages = [];
        /** @var ConstraintViolation $violation */
        foreach ($constraintViolationList as $violation) {
            $violations[] = [
                'propertyPath' => $violation->getPropertyPath(),
                'message' => $violation->getMessage(),
                'code' => $violation->getCode(),
            ];

            $propertyPath = $violation->getPropertyPath();
            $messages[] = ($propertyPath ? $propertyPath.': ' : '').$violation->getMessage();
        }

        return [$messages, $violations];
    }

    /**
     * @param mixed $data
     * @param null $format
     * @return bool
     */
    public function supportsNormalization($data, $format = null): bool
    {
        return $data instanceof ConstraintViolationListInterface;
    }
}

When this class is auto-wired it will automatically be used and in case of validations this will be returned to our clients:

{
    "title": "An error occurred",
    "detail": "price: This value should be greater than 0.",
    "violations": [
        {
            "propertyPath": "price",
            "message": "This value should be greater than 0.",
            "code": "778b7ae0-84d3-481a-9dec-35fdb64b1d78"
        }
    ]
}

This is a lot more practical to use by the clients of our API.