Search code examples
symfonyvalidationsymfony4

Handling Symfony Collection violations for user data


I'm writing an API that will take in a JSON string, parse it and return the requested data. I'm using Symfony's Validation component to do this, but I'm having some issues when validating arrays.

For example, if I have this data:

{
    "format": {
        "type": "foo"
    }
}

Then I can quite easily validate this with PHP code like this:

$constraint = new Assert\Collection(array(
    "fields" => array(
      "format" => new Assert\Collection(array(
        "fields" => array(
          "type" => new Assert\Choice(["foo", "bar"])
        )
      ))
    )
  ));

  $violations = $validator->validate($data, $constraint);

  foreach ($violations as $v) {
      echo $v->getMessage();
  }

If type is neither foo, nor bar, then I get a violation. Even if type is something exotic like a DateTime object, I still get a violation. Easy!

But if I set my data to this:

{
    "format": "uh oh"
}

Then instead of getting a violation (because Assert\Collection expects an array), I get a nasty PHP message:

Fatal error: Uncaught Symfony\Component\Validator\Exception\UnexpectedTypeException: Expected argument of type "array or Traversable and ArrayAccess", "string" given [..]

If there a neat way to handle things like this, without needing to try / catch and handle the error manually, and without having to double up on validation (e.g. one validation to check if format is an array, then another validation to check if type is valid)?

Gist with the full code is here: https://gist.github.com/Grayda/fec0ed7487641645304dee668f2163ac

I'm using Symfony 4


Solution

  • As far as I can see, all built-in validators throw an exception when they are expecting an array but receive something else, so you'll have to write your own validator. You can create a custom validator that first checks if the field is an array, and only then runs the rest of the validators.

    The constraint:

    namespace App\Validation;
    
    use Symfony\Component\Validator\Constraints\Composite;
    
    /**
     * @Annotation
     * @Target({"PROPERTY", "METHOD", "ANNOTATION"})
     */
    class IfArray extends Composite
    {
        public $message = 'This field should be an array.';
    
        public $constraints = array();
    
        public function getDefaultOption()
        {
            return 'constraints';
        }
    
        public function getRequiredOptions()
        {
            return array('constraints');
        }
    
        protected function getCompositeOption()
        {
            return 'constraints';
        }
    }
    

    And the validator:

    namespace App\Validation;
    
    use Symfony\Component\Validator\Constraint;
    use Symfony\Component\Validator\ConstraintValidator;
    use Symfony\Component\Validator\Exception\UnexpectedTypeException;
    
    class IfArrayValidator extends ConstraintValidator
    {
        /**
         * {@inheritdoc}
         */
        public function validate($value, Constraint $constraint)
        {
            if (!$constraint instanceof IfArray) {
                throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\IfArray');
            }
    
            if (null === $value) {
                return;
            }
    
            if (!is_array($value) && !$value instanceof \Traversable) {
                $this->context->buildViolation($constraint->message)
                    ->addViolation();
    
                return;
            }
    
            $context = $this->context;
    
            $validator = $context->getValidator()->inContext($context);
            $validator->validate($value, $constraint->constraints);
        }
    }
    

    Note that this is very similar to the All constraint, with the major difference being that if !is_array($value) && !$value instanceof \Traversable is true, the code will add a violation instead of throwing an exception.

    The new constraint can now be used like this:

    $constraint = new Assert\Collection(array(
            "fields" => array(
                "format" => new IfArray(array(
                    "constraints" => new Assert\Collection(array(
                        "fields" => array(
                            "type" => new Assert\Choice(["foo", "bar"])
                        )
                    ))
                )),
            )
        ));