Skip to content

Validation

Since Jane 7.2.0, you can use the JSON Schema validation specification for your Jane models.

The goal of this feature is to allow your data to be validated based on your schema (either JSON Schema or OpenAPI) and in both ways (if you send data or if you receive data).

At the moment Jane supports most of the specification except for minContains and maxContains for the Array constraints. And for dependentRequired in the Object constraints.

Properties declared with type: string and a format: date or format: date-time also get their format validated (with the Symfony Date / DateTime constraints). The expected format mirrors the full-date-format, date-format and date-input-format configuration options.

Usage

For this example we will take the following JSON Schema as our base model:

{
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "maxLength": 128
        },
        "alcohol": {
            "type": "number",
            "minimum": 0.0
        },
        "year": {
            "type": "integer",
            "minimum": 1000,
            "maximum": 2022
        }
    }
}

From what you can see here, we have some validation rules used in that schema for each properties. To enable the validation features in Jane, you'll have to set the validation parameter to true as following:

<?php

return [
    'json-schema-file' => __DIR__ . '/beer.json',
    'root-class' => 'Beer',
    'namespace' => 'App\Generated',
    'directory' => __DIR__ . '/generated',
    'validation' => true,
];

With that configuration, validators will be generated and during normalization or denormalization they will be called to validate your data. You have nothing to change of your usual Jane calls since all the logic is put into the generated Normalizer.

So with the following code:

<?php

$normalizer = new BeerNormalizer();
$model = $normalizer->denormalize([
    'name' => 'Kasteel Tripel',
    'alcohol' => 11.0,
    'year' => 1811,
], Beer::class);

dump($model);

You will get that model which was validated before returning:

^ App\Generated\Model\Beer^ {#107273
  #name: "Kasteel Tripel"
  #alcohol: 11.0
  #year: 1811
}

In my example, I was using only valid values for all my properties, but if I change the year field to the 811 value, it will cause an error. No model will be returned and an exception will be thrown as following:

^ Jane\Component\JsonSchema\Tests\Validation\Generated\Validator\ValidationException^ {#112477
  #message: "Model validation failed with 1 errors."
  #code: 400
  #file: "./src/Component/JsonSchema/Tests/Validation/Generated/Validator/BeerValidator.php"
  #line: 13
  -violationList: Symfony\Component\Validator\ConstraintViolationList^ {#105008
    -violations: array:1 [
      0 => Symfony\Component\Validator\ConstraintViolation^ {#114455
        -message: "This value should be greater than or equal to 1000."
        -messageTemplate: "This value should be greater than or equal to {{ compared_value }}."
        -parameters: array:3 [
          "{{ value }}" => "811"
          "{{ compared_value }}" => "1000"
          "{{ compared_value_type }}" => "float"
        ]
        -plural: null
        -root: array:3 [
          "name" => "Kasteel Tripel"
          "alcohol" => 11.0
          "year" => 811
        ]
        -propertyPath: "[year]"
        -invalidValue: 811
        -constraint: Symfony\Component\Validator\Constraints\GreaterThanOrEqual^ {#112504
          +payload: null
          +groups: array:1 [
            0 => "Default"
          ]
          +message: "This value should be greater than or equal to {{ compared_value }}."
          +value: 1000.0
          +propertyPath: null
        }
        -code: "ea4e51d1-3342-48bd-87f1-9e672cd90cad"
        -cause: null
      }
    ]
  }
}

To generate validators we use the Symfony Validator component to have independent validator for each generated models. All the constraints for each models will be compiled in that validator and embedded in a Collection constraint that is used to describe all fields and constraints within that model.

For our Beer model, the validator will look like this:

<?php

namespace App\Generated\Validator;

use Symfony\Component\Validator\Constraints;
use Symfony\Component\Validator\Validation;

class BeerValidator implements App\Generated\Validator\ValidatorInterface
{
    public function validate($data) : void
    {
        $constraints = array(
            new Constraints\Collection(array('fields' => array(
                'name' => new Constraints\Optional(array(
                    new Constraints\Length(array(
                        'max' => 128,
                        'maxMessage' => 'This value is too long. It should have {{ limit }} characters or less.'
                    )),
                    new Constraints\Type(array('string'))
                )),
                'alcohol' => new Constraints\Optional(array(
                    new Constraints\GreaterThanOrEqual(array('value' => 0.0)),
                    new Constraints\Type(array('float'))
                )),
                'year' => new Constraints\Optional(array(
                    new Constraints\LessThanOrEqual(array('value' => 2022.0)),
                    new Constraints\GreaterThanOrEqual(array('value' => 1000.0)),
                    new Constraints\Type(array('integer')))
                )),
                'allowExtraFields' => true
            ))
        );

        $validator = Validation::createValidator();
        $violations = $validator->validate($data, $constraints);

        if ($violations->count() > 0) {
            throw new ValidationException($violations);
        }
    }
}

Here we can see that each field has a type constraint to check the value passed and if we have more constraints they will be added accordingly.

Inside the normalizer, you can find that validator usage with:

public function denormalize(mixed $data, string $type, string $format = null, array $context = []) : mixed
{
    $validator = new BeerValidator();
    $validator->validate($data);

    // ...
}

This allows the validation to be done without having anything else than the configuration to do. Also you can use the Validator out of Jane normalization by doing exactly the same as in the Normalizer.

Custom validators

Sometimes the built-in validators are not enough for your specific schemas. Since Jane, you can register additional validators through the validators configuration option.

The validators option takes an array of Jane\Component\JsonSchema\Guesser\Validator\ValidatorInterface instances (not class names), which lets you inject constructor dependencies (like allowed values, configuration, or services).

<?php

return [
    'json-schema-file' => __DIR__ . '/price.json',
    'root-class' => 'Price',
    'namespace' => 'App\Generated',
    'directory' => __DIR__ . '/generated',
    'validation' => true,
    'validators' => [
        new class implements ValidatorInterface {
            public function supports($object): bool
            {
                return $object instanceof JsonSchema && 'decimal-two' === $object->getFormat();
            }

            public function guess($object, string $name, $guess): void
            {
                $guess->addValidatorGuess(new ValidatorGuess(Regex::class, [
                    'pattern' => '#^\d+\.\d{2}$#',
                    'message' => 'This value is not a valid decimal with two fraction digits.',
                ]));
            }
        },
    ],
];

As you can see, each validator implements ValidatorInterface with two methods:

  • supports($object) returns whether this validator applies to the given schema object (for example a JsonSchema with a specific format).
  • guess($object, string $name, $guess) emits the Symfony validation constraints for the guessed property, using $guess->addValidatorGuess(new ValidatorGuess(ConstraintClass::class, [ ...options ])).

Because the option takes instances, you can build validators with constructor dependencies. For example a validator that restricts a custom string format to a fixed list of allowed codes:

new class(['EUR', 'USD', 'GBP']) implements ValidatorInterface {
    public function __construct(
        private readonly array $allowedCodes,
    ) {
    }

    public function supports($object): bool
    {
        return $object instanceof JsonSchema && 'currency-code' === $object->getFormat();
    }

    public function guess($object, string $name, $guess): void
    {
        $guess->addValidatorGuess(new ValidatorGuess(Choice::class, [
            'choices' => $this->allowedCodes,
            'message' => 'This value is not a supported currency code.',
        ]));
    }
},

A single guess() can also emit several constraints. For instance a custom numeric format that becomes a range:

new class implements ValidatorInterface {
    public function supports($object): bool
    {
        return $object instanceof JsonSchema && 'percentage' === $object->getFormat();
    }

    public function guess($object, string $name, $guess): void
    {
        $guess->addValidatorGuess(new ValidatorGuess(GreaterThanOrEqual::class, ['value' => 0.0]));
        $guess->addValidatorGuess(new ValidatorGuess(LessThanOrEqual::class, ['value' => 100.0]));
    }
},

Note that custom validators are checked before the generic Type and NotNull fallback validators, so they take precedence for the schemas they supports(). The validators option is only meaningful when validation is enabled, and it works for both JSON Schema and OpenAPI generation.